ruby-growl 3.0 → 4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,553 @@
1
+ require 'digest'
2
+ require 'openssl'
3
+ require 'time'
4
+ require 'uri'
5
+ require 'uri/x_growl_resource'
6
+ require 'uuid'
7
+
8
+ ##
9
+ # Growl Notification Transport Protocol 1.0
10
+ #
11
+ # In growl 1.3, GNTP replaced the UDP growl protocol from earlier versions.
12
+ # GNTP has some new features beyond those supported in earlier versions
13
+ # including:
14
+ #
15
+ # * Callback support
16
+ # * Notification icons
17
+ # * Encrypted notifications (not supported by growl at this time)
18
+ #
19
+ # Notably, subscription support is not implemented.
20
+ #
21
+ # This implementation is based on information from
22
+ # http://www.growlforwindows.com/gfw/help/gntp.aspx
23
+
24
+ class Growl::GNTP
25
+
26
+ ##
27
+ # Growl GNTP port
28
+
29
+ PORT = 23053
30
+
31
+ ##
32
+ # Base GNTP error class
33
+
34
+ class Error < Growl::Error; end
35
+
36
+ ##
37
+ # Raised when the server indicates a GNTP response error
38
+
39
+ class ResponseError < Error
40
+
41
+ ##
42
+ # The headers from the error response
43
+
44
+ attr_reader :headers
45
+
46
+ ##
47
+ # Creates a new error with +message+ from the response Error-Description
48
+ # header and the full +headers+
49
+
50
+ def initialize message, headers
51
+ super message
52
+
53
+ @headers = headers
54
+ end
55
+
56
+ end
57
+
58
+ ##
59
+ # Raised when the original request was already received by this server
60
+
61
+ class AlreadyProcessed < ResponseError; end
62
+
63
+ ##
64
+ # Raised when the server has an internal error
65
+
66
+ class InternalServerError < ResponseError; end
67
+
68
+ ##
69
+ # Raised when the request was malformed
70
+
71
+ class InvalidRequest < ResponseError; end
72
+
73
+ ##
74
+ # Raised when the server was unavailable or the client could not reach the
75
+ # server
76
+
77
+ class NetworkFailure < ResponseError; end
78
+
79
+ ##
80
+ # Raised when the request supplied a missing or wrong password or was
81
+ # otherwise not authorized
82
+
83
+ class NotAuthorized < ResponseError; end
84
+
85
+ ##
86
+ # Raised when the given notification type was registered but disabled
87
+
88
+ class NotificationDisabled < ResponseError; end
89
+
90
+ ##
91
+ # Raised when the request is missing a required header
92
+
93
+ class RequiredHeaderMissing < ResponseError; end
94
+
95
+ ##
96
+ # Raised when the server timed out waiting for the request to complete
97
+
98
+ class TimedOut < ResponseError; end
99
+
100
+ ##
101
+ # Raised when the application is not registered to send notifications
102
+
103
+ class UnknownApplication < ResponseError; end
104
+
105
+ ##
106
+ # Raised when the notification type was not registered
107
+
108
+ class UnknownNotification < ResponseError; end
109
+
110
+ ##
111
+ # Raised when the request given was not a GNTP request
112
+
113
+ class UnknownProtocol < ResponseError; end
114
+
115
+ ##
116
+ # Raised when the request used an unknown GNTP protocol version
117
+
118
+ class UnknownProtocolVersion < ResponseError; end
119
+
120
+ ERROR_MAP = { # :nodoc:
121
+ 200 => Growl::GNTP::TimedOut,
122
+ 201 => Growl::GNTP::NetworkFailure,
123
+ 300 => Growl::GNTP::InvalidRequest,
124
+ 301 => Growl::GNTP::UnknownProtocol,
125
+ 302 => Growl::GNTP::UnknownProtocolVersion,
126
+ 303 => Growl::GNTP::RequiredHeaderMissing,
127
+ 400 => Growl::GNTP::NotAuthorized,
128
+ 401 => Growl::GNTP::UnknownApplication,
129
+ 402 => Growl::GNTP::UnknownNotification,
130
+ 403 => Growl::GNTP::AlreadyProcessed,
131
+ 404 => Growl::GNTP::NotificationDisabled,
132
+ 500 => Growl::GNTP::InternalServerError,
133
+ }
134
+
135
+ ENCRYPTION_ALGORITHMS = { # :nodoc:
136
+ 'DES' => 'DES-CBC',
137
+ '3DES' => 'DES-EDE3-CBC',
138
+ 'AES' => 'AES-192-CBC',
139
+ }
140
+
141
+ ##
142
+ # Enables encryption for request bodies.
143
+ #
144
+ # Note that this does not appear to be supported in a released version of
145
+ # growl.
146
+
147
+ attr_accessor :encrypt
148
+
149
+ ##
150
+ # Sets the application icon
151
+ #
152
+ # The icon may be any image NSImage supports
153
+
154
+ attr_accessor :icon
155
+
156
+ ##
157
+ # Objects used to generate UUIDs
158
+
159
+ attr_accessor :uuid # :nodoc:
160
+
161
+ ##
162
+ # Hash of notifications registered with the server
163
+
164
+ attr_reader :notifications
165
+
166
+ ##
167
+ # Password for authenticating and encrypting requests. If this is set,
168
+ # authentication automatically takes place.
169
+
170
+ attr_accessor :password
171
+
172
+ ##
173
+ # Creates a new Growl::GNTP instance that will communicate with +host+ and
174
+ # has the given +application+ name, and will send the given
175
+ # +notification_names+.
176
+ #
177
+ # If you wish to set icons or display names for notifications, use
178
+ # add_notification instead of sending +notification_names+.
179
+
180
+ def initialize host, application, notification_names = nil
181
+ @host = host
182
+ @application = application
183
+ @notifications = {}
184
+ @uuid = UUID.new
185
+
186
+ notification_names.each do |name|
187
+ add_notification name
188
+ end if notification_names
189
+
190
+ @encrypt = 'NONE'
191
+ @password = nil
192
+ @icon = nil
193
+ end
194
+
195
+ ##
196
+ # Adds a notification with +name+ (internal) and +display_name+ (shown to
197
+ # user). The +icon+ map be an image (anything NSImage supports) or a URI
198
+ # (which is unsupported in growl 1.3). If the notification is +enabled+ it
199
+ # will be displayed by default.
200
+
201
+ def add_notification name, display_name = nil, icon = nil, enabled = true
202
+ @notifications[name] = display_name, icon, enabled
203
+ end
204
+
205
+ ##
206
+ # Creates a symmetric encryption cipher for +key+ based on the #encrypt
207
+ # method.
208
+
209
+ def cipher key, iv = nil
210
+ algorithm = ENCRYPTION_ALGORITHMS[@encrypt]
211
+
212
+ raise Error, "unknown GNTP encryption mode #{@encrypt}" unless algorithm
213
+
214
+ cipher = OpenSSL::Cipher.new algorithm
215
+ cipher.encrypt
216
+
217
+ cipher.key = key
218
+
219
+ if iv then
220
+ cipher.iv = iv
221
+ else
222
+ iv = cipher.random_iv
223
+ end
224
+
225
+ return cipher, iv
226
+ end
227
+
228
+ ##
229
+ # Creates a TCP connection to the chosen #host
230
+
231
+ def connect
232
+ TCPSocket.new @host, PORT
233
+ end
234
+
235
+ ##
236
+ # Returns an encryption key, authentication hash and random salt for the
237
+ # given hash +algorithm+.
238
+
239
+ def key_hash algorithm
240
+ key = @password.dup.force_encoding Encoding::BINARY
241
+ salt = self.salt
242
+ basis = "#{key}#{salt}"
243
+
244
+ key = algorithm.digest basis
245
+
246
+ hash = algorithm.hexdigest key
247
+
248
+ return key, hash, salt
249
+ end
250
+
251
+ ##
252
+ # Sends a +notification+ with the given +title+ and +text+. The +priority+
253
+ # may be between -2 (lowest) and 2 (highest). +sticky+ will indicate the
254
+ # notification must be manually dismissed. +callback_url+ is supposed to
255
+ # open the given URL on the server's web browser when clicked, but I haven't
256
+ # seen this work.
257
+ #
258
+ # If a block is given, it is called when the notification is clicked, times
259
+ # out, or is manually dismissed.
260
+
261
+ def notify(notification, title, text = nil, priority = 0, sticky = false,
262
+ coalesce_id = nil, callback_url = nil, &block)
263
+
264
+ raise ArgumentError, 'provide either a url or a block for callbacks, ' \
265
+ 'not both' if block and callback_url
266
+
267
+ callback = callback_url || block_given?
268
+
269
+ packet = packet_notify(notification, title, text,
270
+ priority, sticky, coalesce_id, callback)
271
+
272
+ send packet, &block
273
+ end
274
+
275
+ ##
276
+ # Creates a +type+ packet (such as REGISTER or NOTIFY) with the given
277
+ # +headers+ and +resources+. Handles authentication and encryption of the
278
+ # packet.
279
+
280
+ def packet type, headers, resources = {}
281
+ packet = []
282
+
283
+ body = []
284
+ body << "Application-Name: #{@application}"
285
+ body << "Origin-Software-Name: ruby-growl"
286
+ body << "Origin-Software-Version: #{Growl::VERSION}"
287
+ body << "Origin-Platform-Name: ruby"
288
+ body << "Origin-Platform-Version: #{RUBY_VERSION}"
289
+ body << "Connection: close"
290
+ body.concat headers
291
+ body << nil
292
+ body = body.join "\r\n"
293
+
294
+ if @password then
295
+ digest = Digest::SHA512
296
+ key, hash, salt = key_hash digest
297
+ key_info = "SHA512:#{hash}.#{Digest.hexencode salt}"
298
+ end
299
+
300
+ if @encrypt == 'NONE' then
301
+ packet << ["GNTP/1.0", type, "NONE", key_info].compact.join(' ')
302
+ packet << body
303
+ else
304
+ encipher, iv = cipher key
305
+
306
+ encrypt_info = "#{@encrypt}:#{Digest.hexencode iv}"
307
+
308
+ packet << "GNTP/1.0 #{type} #{encrypt_info} #{key_info}"
309
+
310
+ encrypted = encipher.update body
311
+ encrypted << encipher.final
312
+
313
+ packet << encrypted
314
+ end
315
+
316
+ resources.each do |id, data|
317
+ if iv then
318
+ encipher, = cipher key, iv
319
+
320
+ encrypted = encipher.update data
321
+ encrypted << encipher.final
322
+
323
+ data = encrypted
324
+ end
325
+
326
+ packet << "Identifier: #{id}"
327
+ packet << "Length: #{data.length}"
328
+ packet << nil
329
+ packet << data
330
+ packet << nil
331
+ end
332
+
333
+ packet << nil
334
+ packet << nil
335
+
336
+ packet.join "\r\n"
337
+ end
338
+
339
+ ##
340
+ # Creates a notify packet. See #notify for parameter details.
341
+
342
+ def packet_notify(notification, title, text, priority, sticky, coalesce_id,
343
+ callback)
344
+ raise ArgumentError, "invalid priority level #{priority}" unless
345
+ priority >= -2 and priority <= 2
346
+
347
+ resources = {}
348
+ _, icon, = @notifications[notification]
349
+
350
+ if URI === icon then
351
+ icon_uri = icon
352
+ elsif icon then
353
+ id = @uuid.generate
354
+
355
+ resources[id] = icon
356
+ end
357
+
358
+ headers = []
359
+ headers << "Notification-ID: #{@uuid.generate}"
360
+ headers << "Notification-Coalescing-ID: #{coalesce_id}" if coalesce_id
361
+ headers << "Notification-Name: #{notification}"
362
+ headers << "Notification-Title: #{title}"
363
+ headers << "Notification-Text: #{text}" if text
364
+ headers << "Notification-Priority: #{priority}" if priority.nonzero?
365
+ headers << "Notification-Sticky: True" if sticky
366
+ headers << "Notification-Icon: #{icon}" if icon_uri
367
+ headers << "Notification-Icon: x-growl-resource://#{id}" if id
368
+
369
+ if callback then
370
+ headers << "Notification-Callback-Context: context"
371
+ headers << "Notification-Callback-Context-Type: type"
372
+ headers << "Notification-Callback-Target: #{callback}" unless
373
+ callback == true
374
+ end
375
+
376
+ packet :NOTIFY, headers, resources
377
+ end
378
+
379
+ ##
380
+ # Creates a registration packet
381
+
382
+ def packet_register
383
+ resources = {}
384
+
385
+ headers = []
386
+
387
+ case @icon
388
+ when URI then
389
+ headers << "Application-Icon: #{@icon}"
390
+ when NilClass then
391
+ # ignore
392
+ else
393
+ app_icon_id = @uuid.generate
394
+
395
+ headers << "Application-Icon: x-growl-resource://#{app_icon_id}"
396
+
397
+ resources[app_icon_id] = @icon
398
+ end
399
+
400
+ headers << "Notifications-Count: #{@notifications.length}"
401
+ headers << nil
402
+
403
+ @notifications.each do |name, (display_name, icon, enabled)|
404
+ headers << "Notification-Name: #{name}"
405
+ headers << "Notification-Display-Name: #{display_name}" if display_name
406
+ headers << "Notification-Enabled: true" if enabled
407
+
408
+ # This does not appear to be used by growl so ruby-growl sends the
409
+ # icon with every notification.
410
+ if URI === icon then
411
+ headers << "Notification-Icon: #{icon}"
412
+ elsif icon then
413
+ id = @uuid.generate
414
+
415
+ headers << "Notification-Icon: x-growl-resource://#{id}"
416
+
417
+ resources[id] = icon
418
+ end
419
+
420
+ headers << nil
421
+ end
422
+
423
+ headers.pop # remove trailing nil
424
+
425
+ packet :REGISTER, headers, resources
426
+ end
427
+
428
+ ##
429
+ # Parses the +value+ for +header+ into the correct ruby type
430
+
431
+ def parse_header header, value
432
+ return [header, nil] if value == '(null)'
433
+
434
+ case header
435
+ when 'Notification-Enabled',
436
+ 'Notification-Sticky' then
437
+ if value =~ /^(true|yes)$/i then
438
+ [header, true]
439
+ elsif value =~ /^(false|no)$/i then
440
+ [header, false]
441
+ else
442
+ [header, value]
443
+ end
444
+ when 'Notification-Callback-Timestamp' then
445
+ [header, Time.parse(value)]
446
+ when 'Error-Code',
447
+ 'Notifications-Count',
448
+ 'Notifications-Priority',
449
+ 'Subscriber-Port',
450
+ 'Subscription-TTL' then
451
+ [header, value.to_i]
452
+ when 'Application-Name',
453
+ 'Error-Description',
454
+ 'Notification-Callback-Context',
455
+ 'Notification-Callback-Context-Type',
456
+ 'Notification-Callback-Target',
457
+ 'Notification-Coalescing-ID',
458
+ 'Notification-Display-Name',
459
+ 'Notification-ID',
460
+ 'Notification-Name',
461
+ 'Notification-Text',
462
+ 'Notification-Title',
463
+ 'Origin-Machine-Name',
464
+ 'Origin-Platform-Name',
465
+ 'Origin-Platform-Version',
466
+ 'Origin-Software-Version',
467
+ 'Origin-Sofware-Name',
468
+ 'Subscriber-ID',
469
+ 'Subscriber-Name' then
470
+ value.force_encoding Encoding::UTF_8
471
+
472
+ [header, value]
473
+ when 'Application-Icon',
474
+ 'Notification-Icon' then
475
+ value = URI value
476
+ [header, value]
477
+ else
478
+ [header, value]
479
+ end
480
+ end
481
+
482
+ ##
483
+ # Receives and handles the response +packet+ from the server and either
484
+ # raises an error or returns a headers Hash.
485
+
486
+ def receive packet
487
+ $stderr.puts "> #{packet.gsub(/\r\n/, "\n> ")}" if $DEBUG
488
+
489
+ packet = packet.strip.split "\r\n"
490
+
491
+ info = packet.shift
492
+ info =~ %r%^GNTP/([\d.]+) (\S+) (\S+)$%
493
+
494
+ version = $1
495
+ message = $2
496
+ encryption = $3
497
+
498
+ raise Error, "invalid info line #{info.inspect}" unless version
499
+
500
+ headers = packet.flat_map do |header|
501
+ key, value = header.split ': ', 2
502
+
503
+ parse_header key, value
504
+ end
505
+
506
+ headers = Hash[*headers]
507
+
508
+ return headers if %w[-OK -CALLBACK].include? message
509
+
510
+ error_code = headers['Error-Code']
511
+ error_class = ERROR_MAP[error_code]
512
+ error_message = headers['Error-Description']
513
+
514
+ raise error_class.new(error_message, headers)
515
+ end
516
+
517
+ ##
518
+ # Sends a registration packet based on the given notifications
519
+
520
+ def register
521
+ send packet_register
522
+ end
523
+
524
+ ##
525
+ # Creates a random salt for use in authentication and encryption
526
+
527
+ def salt
528
+ OpenSSL::Random.random_bytes 16
529
+ end
530
+
531
+ ##
532
+ # Sends +packet+ to the server and yields a callback, if given
533
+
534
+ def send packet
535
+ socket = connect
536
+
537
+ $stderr.puts "< #{packet.gsub(/\r\n/, "\n< ")}" if $DEBUG
538
+
539
+ socket.write packet
540
+
541
+ result = receive socket.gets "\r\n\r\n\r\n"
542
+
543
+ if block_given? then
544
+ callback = receive socket.gets "\r\n\r\n\r\n"
545
+
546
+ yield callback
547
+ end
548
+
549
+ result
550
+ end
551
+
552
+ end
553
+