ruby-growl 3.0 → 4.0
Sign up to get free protection for your applications and to get access to all the features.
- data.tar.gz.sig +0 -0
- data/.autotest +16 -0
- data/.gemtest +0 -0
- data/History.txt +15 -0
- data/Manifest.txt +7 -1
- data/README.txt +15 -11
- data/Rakefile +4 -1
- data/bin/growl +1 -1
- data/lib/ruby-growl.rb +136 -262
- data/lib/ruby-growl/gntp.rb +553 -0
- data/lib/ruby-growl/ruby_logo.rb +3769 -0
- data/lib/ruby-growl/udp.rb +266 -0
- data/lib/uri/x_growl_resource.rb +76 -0
- data/test/test_growl_gntp.rb +1238 -0
- data/test/{test_ruby_growl.rb → test_growl_udp.rb} +30 -21
- metadata +86 -55
- metadata.gz.sig +0 -0
@@ -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
|
+
|