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.
- 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
|
+
|