growler
Version:
Send notifications to remote and local Growl clients using GNTP
433 lines (392 loc) • 15.1 kB
JavaScript
/**
* @preserve
*
* Node Growler
*
* A node.js Growl server which communicates with Growl clients using GNTP
* (Growl Notification Transport Protocol).
*
* Dependencies:
* - node.js 0.6.0+
* - Underscore.js 1.1.5+
* - openssl determines encryptions available, only affects encrypted
* communication
*
* @author Didrik Nordström, http://betamos.se/
*
* @see http://nodejs.org/
* @see http://growl.info/
*/
var util = require('util'),
events = require('events'),
net = require('net'),
crypto = require('crypto'),
SecurityGuard = require('./security-guard.js'),
_ = require('underscore');
var nl = '\r\n', nl2 = nl+nl;
/**
* Create a Growl application. This is sort of a server which communicates
* with a Growl client over GNTP, a TCP protocol developed specifically for
* Growl. This is also an event emitter with the following events, which mainly
* are used for debugging:
* - request: When a message is sent over the network, first argument will be
* a readable, unencrypted, plain version of the message being sent
* - response: When the Growl client returns a response, first argument will be
* the raw string sent by the Growl client
*
* @see http://growl.info/documentation/developer/gntp.php
*
* @param {string} name Name of the application. Visible in the Growl client
* settings.
*
* @param {Object.<string, *>=} options An object with the following keys:
* - {?string} hostname E.g. '123.45.67.89', 'example.com'.
* Defaults to 'localhost'.
* - {?number} port Network port number. Defaults to 23053, the GNTP port
* - {?number} timeout Timeout for network requests
* - {Buffer} icon An application icon which will appear with all
* notifications sent by this application. Binary image file data.
* - {Object.<string, (string|number|boolean|Buffer|null)>} additionalHeaders
* More GNTP headers to append to every query
*
* @param {Object.<string, ?string>=} security An object with the following keys:
* - password Configured in the Growl client. Normally not required if on the
* the client is on the same machine.
* - hashAlgorithm Required if password given. Available algorithms:
* - MD5 (not very secure)
* - SHA1 (secure, but doesn't provide digest long enough for AES)
* - SHA256 (secure, default)
* - SHA512 (Pentagon)
* - encryptionsAlgorithm Note that Growl 1.3.1 for OS X does NOT support
* encryption yet because of stupid laws in some countries. Therefore
* encryption is disabled by default. In Growl for Windows it works though,
* and these are the available algorithms:
* - AES (recommended)
* - DES
* - 3DES
*/
var GrowlApplication = function(name, options, security) {
this.name = name;
this.options = {};
security = security || {};
_.defaults(this.options, options, {
hostname: 'localhost',
port: 23053,
timeout: 5000, // Socket inactivity timeout
icon: null, // Buffer,
additionalHeaders: {}
});
this.persistentHeaders = {
'Origin-Software-Name': 'Node Growler',
'Origin-Software-Version': '0.0.1'
};
// Extend with user supplied headers
_.defaults(this.persistentHeaders, this.options.additionalHeaders);
// Our guard will take care of all security issues
this.guard = new SecurityGuard(
security.password || null,
security.hashAlgorithm || 'SHA256',
security.encryptionAlgorithm || null);
this.notifications = {};
};
GrowlApplication.prototype = new events.EventEmitter();
// Export using node.js module layer
exports.GrowlApplication = GrowlApplication;
/**
* Set notifications to this GrowlApplication instance. This does NOT send any
* notification, this just makes it possible to register them to the Growl
* client (required). After that it is possible to send notifications.
*
* @param {Object.<string, Object>} notifications An object where keys are the
* names of the notifications and the values are objects with these keys:
* - {?string} displayName: The name of the notification, as seen in the
* Growl client settings. Defaults to the name of the notification.
* - {?boolean} enabled: If this notification should be enabled be default.
* Defaults to true.
* - {Buffer} icon: An image file buffer to display as notification icon.
*/
GrowlApplication.prototype.setNotifications = function(notifications) {
_.each(_.clone(notifications), function(options, name) {
notifications[name] = {};
_.defaults(notifications[name], options, {
displayName: name, // Set display name
enabled: true, // Enabled by default
icon: null
});
});
this.notifications = notifications;
};
/**
* Register this application to the Growl client. All notifications that have
* been added will be registered. If an application is registered multiple
* times with the same name, the previous gets overwritten. It is required to
* register before sending notifications. Registrations are persistent on the
* client so there is no need to register if no updates to the application has
* been made.
*
* @param {?function(boolean, Error=)} callback Always called. Possible errors
* are "not authorized" (usually wrong or lacking password), connection error
*/
GrowlApplication.prototype.register = function(callback) {
var headerBlocks = [{
'Application-Name': this.name,
'Notifications-Count': _.keys(this.notifications).length,
'Application-Icon': this.options.icon
}];
_.each(this.notifications, function(options, name) {
headerBlocks.push({
'Notification-Name': name,
'Notification-Display-Name': options.displayName || name,
'Notification-Enabled': !!options.enabled,
// There is a bug in Growl 1.3.2 which ignores this icon
// @see GrowlApplication.prototype.sendNotification
'Notification-Icon': options.icon
});
});
this.sendQuery('REGISTER', headerBlocks, callback);
};
/**
* Send a notification to the host.
*
* @param {string} name Notification name, must have already been added to the
* GrowlApplication object. Throws an error if it doesn't exist.
*
* @param {Object=} options Additional options object with the following keys:
* - {?string} title: Title of the message on the screen, visible to user
* - {?string} text: Message text, visible to the user
* - {?boolean} sticky: Makes sure notification stays on screen until clicked
* - {Buffer} icon: Image file buffer
*
* @param {?function(boolean, Error=} callback Always called
*
* @return {string} The randomized notification ID
*/
GrowlApplication.prototype.sendNotification = function (name, options, callback) {
var notification = this.notifications[name];
if (!notification)
throw new Error('Cannot find notification with name <'+ name +'>');
var id = crypto.randomBytes(16).toString('hex');
options = options ? _.clone(options) : {};
_.defaults(options, {
title: notification.displayName,
text: null,
sticky: null, // Stay on screen until clicked
priority: null, // In range [-2, 2], 2 meaning emergency
icon: notification.icon,
coalescingID: null
});
var headerBlocks = [{
'Application-Name': this.name,
'Notification-Name': name,
'Notification-ID': id,
'Notification-Title': options.title,
'Notification-Text': options.text,
'Notification-Sticky': options.sticky,
'Notification-Priority': options.priority,
'Notification-Icon': options.icon, // @see GrowlApplication.prototype.register
'Notification-Coalescing-ID': options.coalescingID
}];
this.sendQuery('NOTIFY', headerBlocks, callback);
return id;
};
/* PRIVATE */
/**
* Assemble a query into a message string and attachments as buffers.
* One possible side effect is that the headerBlocks object may be altered.
*
* @param {Array.<Object.<string, (string|number|boolean|Buffer|null)>>}
* headerBlocks An array of header blocks, where each block is an object with
* a string key and one of these values:
* - string: (GNTP <string>)
* - number: (GNTP <int>) Will run through parseInt to assure integer
* - boolean: (GNTP <boolean>) May NOT be null, see below.
* - Buffer: (GNTP <uniqueid>) for sending binary data, e.g. an image.
* - null: omits the entire header
*
* @return {Object} An object with the keys:
* - {string} message: The message, does NOT begin nor end with CRLF
* - {Object.<string, !Buffer>} attachments: Keys are the GNTP <uniqueid>'s,
* values are their corresponding buffers.
*/
GrowlApplication.prototype.assembleQuery = function(headerBlocks) {
var self = this,
blocks = [],
attachments = {}; // An object with <uniqueid> as keys and buffers as values
_.each(headerBlocks, function(header, index) {
var lines = [];
if (index == 0) {
// Additional headers belongs to the first block
_.defaults(header, self.persistentHeaders);
}
_.each(header, function(value, key) {
if (typeof key != 'string' || value == null)
return;
// Special case for buffers, they will be treated as attachments
if (Buffer.isBuffer(value)) {
// Create an md5 hash of the buffer
var hash = crypto.createHash('md5');
hash.update(value);
var digest = hash.digest('hex');
// Add to binary
attachments[digest] = value;
// Point to the binary attachment and alter value
value = 'x-growl-resource://'+ digest;
}
// Alter value so that it is completely GNTP safe
switch (typeof value) {
case 'string':
break;
case 'number':
value = parseInt(value);
if (isNaN(value))
return;
break;
case 'boolean':
value = value ? 'True' : 'False';
break;
default:
return;
}
// If everything worked out, add the line
lines.push(key +': '+ value);
});
blocks.push(lines.join(nl));
});
return {
message: blocks.join(nl2),
attachments: attachments
};
};
/**
* Retrieve the GNTP information line which often looks something like
* "GNTP/1.0 REGISTER NONE" or similar.
*
* @param {string} messageType GNTP message type, e.g. 'REGISTER'
*
* @return {string} GNTP information line, without CRLF.
*/
GrowlApplication.prototype.getInfoLine = function(messageType) {
var infoLine = 'GNTP/1.0 '+ messageType +' ';
if (this.guard.encAlg) // Encryption enabled
infoLine += this.guard.encAlg +':'+ this.guard.iv.toString('hex');
else // No encryption
infoLine += 'NONE';
if (this.guard.hashAlg) // Password protection
infoLine += ' '+ this.guard.hashAlg +':'+
this.guard.keyHash +'.'+ this.guard.salt.toString('hex');
// Actually, GNTP only requires the algorithm id (e.g. sha1) to be uppercase
// but their example GNTP information lines are all uppercase so better be safe.
return infoLine.toUpperCase();
};
/**
* Send a query and wait for response, then call provided callback.
* Takes care of calling appropriate security methods and encryption.
*
* @param {string} messageType The GNTP message type, e.g. "NOTIFY"
*
* @param {Array.<Object.<string, (string|number|boolean|Buffer|null)>>}
* headerBlocks A list of header objects.
* @see GrowlApplication.prototype.assembleQuery
*
* @param {function(boolean, Error=)} callback
*
*/
GrowlApplication.prototype.sendQuery = function(messageType, headerBlocks, callback) {
var self = this;
var socket = new net.Socket();
callback = callback || function() {};
// Since neither Growl for OS X or Windows encrypt their responses,
// presume plain text
socket.setEncoding('utf8');
// Connect
socket.connect(this.options.port, this.options.hostname, function() {
// Retrieve the data that shall be sent
var data = self.assembleQuery(headerBlocks),
infoLine = self.getInfoLine(messageType);
var readableRequest = infoLine + nl + data.message;
// Information line never encrypted
socket.write(infoLine+nl);
self.guard.writeSecure(socket, data.message);
_.each(data.attachments, function(buffer, uniqueid) {
// Anonymous self-invoking function are useful sometimes
(function(binaryHeader) {
socket.write(binaryHeader);
readableRequest += binaryHeader;
})(nl2 +'Identifier: '+ uniqueid + nl +'Length: '+ buffer.length + nl2);
self.guard.writeSecure(socket, buffer);
readableRequest += '<'+ buffer.length +' bytes of binary data>';
});
socket.write(nl2);
readableRequest += nl2;
self.emit('request', readableRequest);
});
var recieved = '';
// Aggregate data before parsing
socket.on('data', function(data) {
recieved += data;
});
// Actively wait for remote to close socket
socket.once('close', function(error) {
self.emit('response', recieved);
var response = self.parseResponse(recieved);
if (response && response.status) // All good
callback(true);
else if (response) { // GNTP responded with error
var e = new Error('Host: '+
(response.headers['Error-Description'] ? response.headers['Error-Description'] : ''));
e.errorCode = response.headers['Error-Code'];
callback(false, e);
}
else // Not even valid GNTP
callback(false, new Error('The response was invalid GNTP.'));
});
// Exception management
socket.on('error', function(exception) {
// Could probably not connect to server
callback(false, exception);
});
// We can not wait forever
socket.setTimeout(this.options.timeout, function() {
socket.destroy();
callback(false, new Error('Server did not respond'));
});
};
/**
* Parses a raw response string into an object with information about the status
* and other headers. Ignores lines that are not "key: value" structured.
*
* @param {string} data Raw response string
*
* @return {Object} If malformed GNTP response information line, then null
* or else an object with the following keys:
* - {boolean} status: Message type, true for '-OK', false for '-ERROR'
* - {Object.<string, string>} headers: An object with keys and values
* corresponding to the GNTP headers, e.g.
* - 'Response-Action': 'NOTIFY',
* - 'Error-Code': '402'
*/
GrowlApplication.prototype.parseResponse = function(data) {
var lines = data.split(nl),
matches;
// Check for valid GNTP header
if (!(lines.length &&
(matches = /^GNTP\/1\.0\s\-(OK|ERROR)\sNONE\s*$/.exec(lines.shift())) &&
matches.length == 2))
return null; // Invalid, return null
var status = matches[1] == 'OK';
var headers = {};
_(lines).chain()
.filter(function(line) { return /^.+:\s.*$/.test(line); })
.map(function(line) {
// Match key: value pair
var matches = /^(.+):\s(.*)$/.exec(line);
if (!matches || matches.length < 3)
throw new Error('GNTP Module internal error');
headers[matches[1]] = matches[2];
})
.value(); // End chain
return {
status: status,
headers: headers
};
};