ruby-growl 3.0 → 4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+