├── .gitignore ├── bin └── growl ├── test ├── rocketAlpha.jpg ├── test_growl_udp.rb └── test_growl_gntp.rb ├── .travis.yml ├── Manifest.txt ├── .autotest ├── Rakefile ├── lib ├── uri │ └── x_growl_resource.rb ├── ruby-growl │ ├── udp.rb │ └── gntp.rb └── ruby-growl.rb ├── History.txt └── README.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | /.rdoc 3 | /doc 4 | /pkg 5 | TAGS 6 | -------------------------------------------------------------------------------- /bin/growl: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/ruby 2 | 3 | require 'ruby-growl' 4 | 5 | Growl.run ARGV 6 | 7 | -------------------------------------------------------------------------------- /test/rocketAlpha.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drbrain/ruby-growl/HEAD/test/rocketAlpha.jpg -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | after_script: 3 | - rake travis:after -t 4 | before_script: 5 | - gem install hoe-travis --no-rdoc --no-ri 6 | - rake travis:before -t 7 | language: ruby 8 | notifications: 9 | email: 10 | - drbrain@segment7.net 11 | rvm: 12 | - 1.9.2 13 | - 1.9.3 14 | - 2.0.0 15 | - 2.1.0 16 | script: rake travis 17 | -------------------------------------------------------------------------------- /Manifest.txt: -------------------------------------------------------------------------------- 1 | .autotest 2 | History.txt 3 | Manifest.txt 4 | README.txt 5 | Rakefile 6 | bin/growl 7 | lib/ruby-growl.rb 8 | lib/ruby-growl/gntp.rb 9 | lib/ruby-growl/ruby_logo.rb 10 | lib/ruby-growl/udp.rb 11 | lib/uri/x_growl_resource.rb 12 | test/rocketAlpha.jpg 13 | test/test_growl_gntp.rb 14 | test/test_growl_udp.rb 15 | -------------------------------------------------------------------------------- /.autotest: -------------------------------------------------------------------------------- 1 | require 'autotest/restart' 2 | 3 | Autotest.add_hook :initialize do |at| 4 | at.add_exception '.git' 5 | at.testlib = 'minitest/autorun' 6 | at.unit_diff = 'cat' 7 | 8 | def at.path_to_classname s 9 | sep = File::SEPARATOR 10 | f = s.sub(/^test#{sep}/, '').sub(/\.rb$/, '').split(sep) 11 | f = f.map { |path| path.split(/_|(\d+)/).map { |seg| seg.capitalize }.join } 12 | f = f.map { |path| path =~ /^Test/ ? path : "Test#{path}" } 13 | f.join('::').gsub('Gntp', 'GNTP').gsub('Udp', 'UDP') 14 | end 15 | end 16 | 17 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'hoe' 3 | 4 | Hoe.plugin :git 5 | Hoe.plugin :minitest 6 | Hoe.plugin :travis 7 | 8 | Hoe.spec 'ruby-growl' do 9 | developer 'Eric Hodel', 'drbrain@segment7.net' 10 | 11 | spec_extras['required_ruby_version'] = '>= 1.9.2' 12 | 13 | rdoc_locations << 'docs.seattlerb.org:/data/www/docs.seattlerb.org/ruby-growl/' 14 | rdoc_locations << 'drbrain@rubyforge.org:/var/www/gforge-projects/ruby-growl/' 15 | 16 | license 'BSD 3-clause' 17 | 18 | extra_deps << ['uuid', '~> 2.3', '>= 2.3.5'] 19 | dependency 'minitest', '~> 5.0', :developer 20 | end 21 | 22 | -------------------------------------------------------------------------------- /lib/uri/x_growl_resource.rb: -------------------------------------------------------------------------------- 1 | class URI::XGrowlResource < URI::Generic 2 | 3 | DEFAULT_PORT = nil 4 | 5 | COMPONENT = [ :scheme, :unique_id ] 6 | 7 | UNIQUE_ID_REGEXP = /\A[\w-]+\z/ 8 | 9 | attr_reader :unique_id 10 | 11 | def self.build args 12 | tmp = URI::Util.make_components_hash self, args 13 | 14 | if tmp[:unique_id] then 15 | tmp[:host] = tmp[:unique_id] 16 | else 17 | tmp[:host] = '' 18 | end 19 | 20 | super tmp 21 | end 22 | 23 | def initialize *args 24 | super 25 | 26 | @unique_id = nil 27 | 28 | if UNIQUE_ID_REGEXP =~ @host then 29 | if args[-1] then # arg_check 30 | self.unique_id = @host 31 | else 32 | set_unique_id @host 33 | end 34 | else 35 | raise URI::InvalidComponentError, 36 | "unrecognized opaque part for x-growl-resource URL: #{@host}" 37 | end 38 | end 39 | 40 | def to_s # :nodoc: 41 | "#{@scheme}://#{@unique_id}" 42 | end 43 | 44 | def unique_id= v 45 | check_unique_id v 46 | set_unique_id v 47 | end 48 | 49 | # :stopdoc: 50 | 51 | protected 52 | 53 | def set_unique_id v 54 | @unique_id = v 55 | end 56 | 57 | private 58 | 59 | def check_unique_id v 60 | return true unless v 61 | return true if v.empty? 62 | 63 | if parser.regexp[:HOST] !~ v or UNIQUE_ID_REGEXP !~ v then 64 | raise InvalidComponentError, 65 | "bad component (expected unique ID component): #{v}" 66 | end 67 | 68 | true 69 | end 70 | 71 | end 72 | 73 | module URI # :nodoc: 74 | @@schemes['X-GROWL-RESOURCE'] = URI::XGrowlResource 75 | end 76 | 77 | -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | === 4.1 / 2014-02-14 2 | 3 | Enhancements: 4 | 5 | * Support for HTTPS resources (NSImage icons). Pull request #10 by Barry 6 | Allard. 7 | * Added UDP growl broadcast support via the API only. This does not work with 8 | growl 2.x as UDP support was removed. Issue #9 by Osborne Brook 9 | Partnership. 10 | 11 | Bug fixes: 12 | 13 | * Fixed documentation link in README. Issue #7 by Robby Colvin. 14 | * Fixed handling of UTF-8 messages with icon files. Issue #10 by kunigaku. 15 | 16 | === 4 / 2012-04-04 17 | 18 | * API changes: 19 | * Growl is now a wrapper for Growl::UDP (1.2 and older) and Growl::GNTP (1.3 20 | and newer). The main difference is that notifications need to be 21 | registered with Growl#add_notification instead of via Growl#initialize. 22 | * Ruby 1.9.2 or newer is required to use ruby-growl 23 | 24 | * Major enhancements 25 | * Added GNTP protocol support for registration and notification including 26 | application and notification icons and callbacks. 27 | * Moved UDP protocol support to Growl::UDP 28 | * Growl automatically determines if the growl server supports GNTP or UDP 29 | and uses the best protocol. 30 | 31 | === 3.0 / 2010-09-11 32 | 33 | * Major enhancement 34 | * Dropped support for ruby 1.8.6 and older. 35 | 36 | === 2.1 / 2010-09-11 37 | 38 | * Minor enhancement 39 | * Use String#bytesize if available. Patch by SAWADA Tadashi. 40 | 41 | === 2.0 / 2010-06-11 42 | 43 | * Major Enhancements 44 | * 1.9-ready 45 | * Notification levels now work on x86 46 | * Notification levels no longer work on PPC 47 | 48 | * Minor Enhancements 49 | * Added Growl.list which finds local hosts running growl using dnssd. 50 | 51 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | = ruby-growl 2 | 3 | home :: https://github.com/drbrain/ruby-growl 4 | bugs :: https://github.com/drbrain/ruby-growl/issues 5 | rdoc :: http://docs.seattlerb.org/ruby-growl 6 | 7 | == DESCRIPTION: 8 | 9 | A pure-ruby growl notifier for UDP and GNTP growl protocols. ruby-growl 10 | allows you to perform Growl notifications from machines without growl 11 | installed (for example, non-OSX machines). 12 | 13 | What is growl? Growl is a really cool "global notification system originally 14 | for Mac OS X". 15 | 16 | You can receive Growl notifications on various platforms and send them from 17 | any machine that runs Ruby. 18 | 19 | OS X: http://growl.info 20 | Windows: http://www.growlforwindows.com/gfw/ 21 | Linux: http://github.com/mattn/growl-for-linux 22 | 23 | ruby-growl also contains a command-line notification tool named 'growl'. It 24 | is almost completely option-compatible with growlnotify. (All except for -p 25 | is supported, use --priority instead.) 26 | 27 | == FEATURES/PROBLEMS: 28 | 29 | * Requires "Listen for incoming notifications" enabled on the growl server 30 | 31 | == INSTALL: 32 | 33 | gem install ruby-growl 34 | 35 | == LICENSE: 36 | 37 | Copyright Eric Hodel. All rights reserved. 38 | 39 | Redistribution and use in source and binary forms, with or without 40 | modification, are permitted provided that the following conditions 41 | are met: 42 | 43 | 1. Redistributions of source code must retain the above copyright 44 | notice, this list of conditions and the following disclaimer. 45 | 2. Redistributions in binary form must reproduce the above copyright 46 | notice, this list of conditions and the following disclaimer in the 47 | documentation and/or other materials provided with the distribution. 48 | 3. Neither the names of the authors nor the names of their contributors 49 | may be used to endorse or promote products derived from this software 50 | without specific prior written permission. 51 | 52 | THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS 53 | OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 54 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 55 | ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE 56 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, 57 | OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT 58 | OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 59 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 60 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 61 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, 62 | EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 63 | 64 | -------------------------------------------------------------------------------- /lib/ruby-growl/udp.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Implements the UDP growl protocol used in growl 1.2 and older. 3 | 4 | class Growl::UDP 5 | 6 | ## 7 | # The Ruby that ships with Tiger has a broken #pack, so 'v' means network 8 | # byte order instead of 'n'. 9 | 10 | BROKEN_PACK = [1].pack("n") != "\000\001" # :nodoc: 11 | 12 | little_endian = [1].pack('V*') == [1].pack('L*') 13 | little_endian = !little_endian if BROKEN_PACK 14 | 15 | ## 16 | # Endianness of this machine 17 | 18 | LITTLE_ENDIAN = little_endian 19 | 20 | ## 21 | # Growl Network Registration Packet +pack+ Format 22 | #-- 23 | # Format: 24 | # 25 | # struct GrowlNetworkRegistration { 26 | # struct GrowlNetworkPacket { 27 | # unsigned char version; 28 | # unsigned char type; 29 | # } __attribute__((packed)); 30 | # unsigned short appNameLen; 31 | # unsigned char numAllNotifications; 32 | # unsigned char numDefaultNotifications; 33 | # /* 34 | # * Variable sized. Format: 35 | # * 36 | # * where is of the form (){num} and 37 | # * is an array of indices into the all notifications 38 | # * array, each index being 8 bits. 39 | # */ 40 | # unsigned char data[]; 41 | # } __attribute__((packed)); 42 | 43 | GNR_FORMAT = "CCnCCa*" 44 | 45 | GNR_FORMAT.gsub!(/n/, 'v') if BROKEN_PACK 46 | 47 | ## 48 | # Growl Network Notification Packet +pack+ Format 49 | #-- 50 | # Format: 51 | # 52 | # struct GrowlNetworkNotification { 53 | # struct GrowlNetworkPacket { 54 | # unsigned char version; 55 | # unsigned char type; 56 | # } __attribute__((packed)); 57 | # struct GrowlNetworkNotificationFlags { 58 | # unsigned reserved: 12; 59 | # signed priority: 3; 60 | # unsigned sticky: 1; 61 | # } __attribute__((packed)) flags; //size = 16 (12 + 3 + 1) 62 | # unsigned short nameLen; 63 | # unsigned short titleLen; 64 | # unsigned short descriptionLen; 65 | # unsigned short appNameLen; 66 | # /* 67 | # * Variable sized. Format: 68 | # * <description><application name><checksum> 69 | # */ 70 | # unsigned char data[]; 71 | # } __attribute__((packed)); 72 | 73 | GNN_FORMAT = "CCnnnnna*" 74 | 75 | GNN_FORMAT.gsub!(/n/, 'v') if BROKEN_PACK 76 | 77 | # For litle endian machines the NetworkNotificationFlags aren't in network 78 | # byte order 79 | 80 | GNN_FORMAT.sub!((BROKEN_PACK ? 'v' : 'n'), 'v') if LITTLE_ENDIAN 81 | 82 | ## 83 | # Growl Protocol Version 84 | 85 | GROWL_PROTOCOL_VERSION = 1 86 | 87 | ## 88 | # Growl Registration Packet Id 89 | 90 | GROWL_TYPE_REGISTRATION = 0 91 | 92 | ## 93 | # Growl Notification Packet Id 94 | 95 | GROWL_TYPE_NOTIFICATION = 1 96 | 97 | ## 98 | # Growl UDP Port 99 | 100 | PORT = 9887 101 | 102 | ## 103 | # Creates a new Growl UDP notifier and automatically registers any 104 | # notifications with the remote machine. 105 | # 106 | # +host+ is the host to contact. 107 | # 108 | # +app_name+ is the name of the application sending the notifications. 109 | # 110 | # +all_notifies+ is a list of notification types your application sends. 111 | # 112 | # +default_notifies+ is a list of notification types that are turned on by 113 | # default. 114 | # 115 | # I'm not sure about what +default_notifies+ is supposed to be set to, since 116 | # there is a comment that says "not a subset of all_notifies" in the code. 117 | # 118 | # +password+ is the password needed to send notifications to +host+. 119 | 120 | def initialize(host, app_name, all_notifies, default_notifies = nil, 121 | password = nil) 122 | @socket = socket host 123 | @app_name = app_name 124 | @all_notifies = all_notifies 125 | @default_notifies = default_notifies.nil? ? all_notifies : default_notifies 126 | @password = password 127 | 128 | register 129 | end 130 | 131 | ## 132 | # Sends a notification. 133 | # 134 | # +notify_type+ is the type of notification to send. 135 | # 136 | # +title+ is a title for the notification. 137 | # 138 | # +message+ is the body of the notification. 139 | # 140 | # +priority+ is the priorty of message to send. 141 | # 142 | # +sticky+ makes the notification stick until clicked. 143 | 144 | def notify(notify_type, title, message, priority = 0, sticky = false) 145 | raise Growl::Error, "Unknown Notification" unless 146 | @all_notifies.include? notify_type 147 | 148 | raise Growl::Error, "Invalid Priority" unless 149 | priority >= -2 and priority <= 2 150 | 151 | send notification_packet(notify_type, title, message, priority, sticky) 152 | end 153 | 154 | ## 155 | # Registers the notification types with +host+. 156 | 157 | def register 158 | send registration_packet 159 | end 160 | 161 | ## 162 | # Sends a Growl packet 163 | 164 | def send(packet) 165 | set_sndbuf packet.length 166 | @socket.send packet, 0 167 | @socket.flush 168 | end 169 | 170 | ## 171 | # Builds a Growl registration packet 172 | 173 | def registration_packet 174 | data = [] 175 | data_format = "" 176 | 177 | packet = [ 178 | GROWL_PROTOCOL_VERSION, 179 | GROWL_TYPE_REGISTRATION 180 | ] 181 | 182 | packet << @app_name.bytesize 183 | packet << @all_notifies.length 184 | packet << @default_notifies.length 185 | 186 | data << @app_name 187 | data_format = "a#{@app_name.bytesize}" 188 | 189 | @all_notifies.each do |notify| 190 | data << notify.length 191 | data << notify 192 | data_format << "na#{notify.length}" 193 | end 194 | 195 | @default_notifies.each do |notify| 196 | data << @all_notifies.index(notify) if @all_notifies.include? notify 197 | data_format << "C" 198 | end 199 | 200 | data_format.gsub!(/n/, 'v') if BROKEN_PACK 201 | 202 | data = data.pack data_format 203 | 204 | packet << data 205 | 206 | packet = packet.pack GNR_FORMAT 207 | 208 | checksum = Digest::MD5.new << packet 209 | checksum.update @password unless @password.nil? 210 | 211 | packet << checksum.digest 212 | 213 | return packet 214 | end 215 | 216 | ## 217 | # Builds a Growl notification packet 218 | 219 | def notification_packet(name, title, description, priority, sticky) 220 | flags = 0 221 | data = [] 222 | 223 | packet = [ 224 | GROWL_PROTOCOL_VERSION, 225 | GROWL_TYPE_NOTIFICATION, 226 | ] 227 | 228 | flags = 0 229 | flags |= ((0x7 & priority) << 1) # 3 bits for priority 230 | flags |= 1 if sticky # 1 bit for sticky 231 | 232 | packet << flags 233 | packet << name.bytesize 234 | packet << title.length 235 | packet << description.bytesize 236 | packet << @app_name.bytesize 237 | 238 | data << name 239 | data << title 240 | data << description 241 | data << @app_name 242 | 243 | packet << data.join 244 | packet = packet.pack GNN_FORMAT 245 | 246 | checksum = Digest::MD5.new << packet 247 | checksum.update @password unless @password.nil? 248 | 249 | packet << checksum.digest 250 | 251 | return packet 252 | end 253 | 254 | ## 255 | # Set the size of the send buffer 256 | #-- 257 | # Is this truly necessary? 258 | 259 | def set_sndbuf(length) 260 | @socket.setsockopt Socket::SOL_SOCKET, Socket::SO_SNDBUF, length 261 | end 262 | 263 | def socket host 264 | addrinfo = Addrinfo.udp host, PORT 265 | 266 | socket = Socket.new addrinfo.pfamily, addrinfo.socktype, addrinfo.protocol 267 | 268 | if addrinfo.ip_address == '255.255.255.255' then 269 | socket.setsockopt :SOL_SOCKET, :SO_BROADCAST, true 270 | elsif Socket.respond_to?(:getifaddrs) and 271 | Socket.getifaddrs.any? do |ifaddr| 272 | ifaddr.broadaddr and 273 | ifaddr.broadaddr.ip_address == addrinfo.ip_address 274 | end then 275 | socket.setsockopt :SOL_SOCKET, :SO_BROADCAST, true 276 | end 277 | 278 | socket.connect addrinfo 279 | 280 | socket 281 | end 282 | 283 | end 284 | 285 | -------------------------------------------------------------------------------- /lib/ruby-growl.rb: -------------------------------------------------------------------------------- 1 | require 'digest/md5' 2 | require 'socket' 3 | 4 | begin 5 | require 'dnssd' 6 | rescue LoadError 7 | end 8 | 9 | ## 10 | # ruby-growl allows you to perform Growl notifications from machines without 11 | # growl installed (for example, non-OSX machines). 12 | # 13 | # In version 4, the Growl class is a wrapper for Growl::UDP and Growl::GNTP. 14 | # The GNTP protocol allows setting icons for notifications and callbacks. To 15 | # upgrade from version 3 replace the notification names passed to initialize 16 | # with a call to #add_notification. 17 | # 18 | # Basic usage: 19 | # 20 | # require 'ruby-growl' 21 | # 22 | # g = Growl.new "localhost", "ruby-growl" 23 | # g.add_notification "ruby-growl Notification" 24 | # g.notify "ruby-growl Notification", "It came from ruby-growl!", 25 | # "Greetings!" 26 | # 27 | # For GNTP users, ruby-growl ships with the Ruby icon from the {Ruby Visual 28 | # Identity Team}[http://rubyidentity.org/]: 29 | # 30 | # require 'ruby-growl' 31 | # require 'ruby-growl/ruby_logo' 32 | # 33 | # g = Growl.new "localhost", "ruby-growl" 34 | # g.add_notification("notification", "ruby-growl Notification", 35 | # Growl::RUBY_LOGO_PNG) 36 | # g.notify "notification", "It came from ruby-growl", "Greetings!" 37 | # 38 | # See Growl::UDP and Growl::GNTP for protocol-specific API. 39 | 40 | class Growl 41 | 42 | ## 43 | # ruby-growl version 44 | 45 | VERSION = '4.1' 46 | 47 | ## 48 | # Growl error base class 49 | 50 | class Error < RuntimeError 51 | end 52 | 53 | ## 54 | # Password for authenticating and encrypting requests. 55 | 56 | attr_accessor :password 57 | 58 | ## 59 | # List of hosts accessible via dnssd 60 | 61 | def self.list type 62 | raise 'you must gem install dnssd' unless Object.const_defined? :DNSSD 63 | 64 | require 'timeout' 65 | 66 | growls = [] 67 | 68 | begin 69 | Timeout.timeout 10 do 70 | DNSSD.browse! type do |reply| 71 | next unless reply.flags.add? 72 | 73 | growls << reply 74 | 75 | break unless reply.flags.more_coming? 76 | end 77 | end 78 | rescue Timeout::Error 79 | end 80 | 81 | hosts = [] 82 | 83 | growls.each do |growl| 84 | DNSSD.resolve! growl do |reply| 85 | hosts << reply.target 86 | break 87 | end 88 | end 89 | 90 | hosts.uniq 91 | end 92 | 93 | ## 94 | # Sends a notification using +options+ 95 | 96 | def self.notify options 97 | message = options[:message] 98 | 99 | unless message then 100 | puts "Type your message and hit ^D" if $stdin.tty? 101 | message = $stdin.read 102 | end 103 | 104 | notify_type = options[:notify_type] 105 | 106 | g = new options[:host], options[:name] 107 | g.add_notification notify_type, options[:name], options[:icon] 108 | g.password = options[:password] 109 | 110 | g.notify(notify_type, options[:title], message, options[:priority], 111 | options[:sticky]) 112 | end 113 | 114 | ## 115 | # Parses argv-style options from +ARGV+ into an options hash 116 | 117 | def self.process_args argv 118 | require 'optparse' 119 | 120 | options = { 121 | host: nil, 122 | icon: nil, 123 | list: false, 124 | message: nil, 125 | name: "ruby-growl", 126 | notify_type: "ruby-growl Notification", 127 | password: nil, 128 | priority: 0, 129 | sticky: false, 130 | title: "", 131 | } 132 | 133 | opts = OptionParser.new do |o| 134 | o.program_name = File.basename $0 135 | o.version = Growl::VERSION 136 | o.release = nil 137 | 138 | o.banner = <<-BANNER 139 | Usage: #{o.program_name} -H HOSTNAME [options] 140 | 141 | Where possible, growl is compatible with growlnotify's arguments. 142 | (Except for -p, use --priority) 143 | 144 | Synopsis: 145 | echo \"message\" | growl -H localhost 146 | 147 | growl -H localhost -m message 148 | 149 | BANNER 150 | 151 | o.separator "Options:" 152 | 153 | o.on("-H", "--host HOSTNAME", "Send notifications to HOSTNAME") do |val| 154 | options[:host] = val 155 | end 156 | 157 | o.on("-i", "--icon [ICON]", "Icon url") do |val| 158 | options[:icon] = URI(val) 159 | end 160 | 161 | o.on("-n", "--name [NAME]", "Sending application name", 162 | "(Defaults to \"ruby-growl\")") do |val| 163 | options[:name] = val 164 | end 165 | 166 | o.on("-y", "--type [TYPE]", "Notification type", 167 | "(Defauts to \"Ruby Growl Notification\")") do |val| 168 | options[:notify_type] = val 169 | end 170 | 171 | o.on("-t", "--title [TITLE]", "Notification title") do |val| 172 | options[:title] = val 173 | end 174 | 175 | o.on("-m", "--message [MESSAGE]", 176 | "Send this message instead of reading STDIN") do |val| 177 | options[:message] = val 178 | end 179 | 180 | # HACK -p -1 raises 181 | o.on("--priority [PRIORITY]", Integer, 182 | "Notification priority", 183 | "Priority can be between -2 and 2") do |val| 184 | options[:priority] = val 185 | end 186 | 187 | o.on("-s", "--[no-]sticky", "Make the notification sticky") do |val| 188 | options[:sticky] = val 189 | end 190 | 191 | o.on("-P", "--password [PASSWORD]", "Growl UDP Password") do |val| 192 | options[:password] = val 193 | end 194 | 195 | o.on("--list", "List growl hosts using dnssd") do |val| 196 | options[:list] = true 197 | end 198 | end 199 | 200 | opts.parse! argv 201 | 202 | abort opts.to_s unless options[:host] or options[:list] 203 | 204 | options 205 | end 206 | 207 | ## 208 | # Command-line interface 209 | 210 | def self.run argv = ARGV 211 | options = process_args argv 212 | 213 | if options[:list] then 214 | begin 215 | puts 'Growl GNTP hosts:' 216 | puts list '_gntp._tcp' 217 | puts 218 | puts 'Growl UDP hosts:' 219 | puts list '_growl._tcp' 220 | rescue => e 221 | raise unless e.message =~ /gem install dnssd/ 222 | 223 | abort "#{e.message} to use --list" 224 | end 225 | return 226 | end 227 | 228 | notify options 229 | end 230 | 231 | ## 232 | # Creates a new growl basic notifier for +host+ and +application_name+. 233 | # 234 | # +growl_type+ is used to specify the type of growl server to connect to. 235 | # The following values are allowed: 236 | # 237 | # nil:: 238 | # Automatically determine the growl type. If a GNTP server is not found 239 | # then ruby-growl chooses UDP. 240 | # 'GNTP':: 241 | # Use GNTP connections. GNTP is supported by Growl 1.3 and newer and by 242 | # Growl for Windows. 243 | # 'UDP':: 244 | # Uses the UDP growl protocol. UDP growl is supported by Growl 1.2 and 245 | # older. 246 | # 247 | # You can use <tt>growl --list</tt> to see growl servers on your local 248 | # network. 249 | 250 | def initialize host, application_name, growl_type = nil 251 | @host = host 252 | @application_name = application_name 253 | 254 | @notifications = {} 255 | @password = nil 256 | 257 | @growl_type = choose_implementation growl_type 258 | end 259 | 260 | ## 261 | # Adds a notification named +name+ to the basic notifier. For GNTP servers 262 | # you may specify a +display_name+ and +icon+ and set the default +enabled+ 263 | # status. 264 | 265 | def add_notification name, display_name = nil, icon = nil, enabled = true 266 | @notifications[name] = display_name, icon, enabled 267 | end 268 | 269 | def choose_implementation type # :nodoc: 270 | raise ArgumentError, 271 | "type must be \"GNTP\", \"UDP\" or nil; was #{type.inspect}" unless 272 | ['GNTP', 'UDP', nil].include? type 273 | 274 | return type if type 275 | 276 | TCPSocket.open @host, Growl::GNTP::PORT do end 277 | 278 | 'GNTP' 279 | rescue SystemCallError 280 | 'UDP' 281 | end 282 | 283 | ## 284 | # Sends a notification of type +name+ with the given +title+, +message+, 285 | # +priority+ and +sticky+ settings. 286 | 287 | def notify name, title, message, priority = 0, sticky = false, icon = nil 288 | case @growl_type 289 | when 'GNTP' then 290 | notify_gntp name, title, message, priority, sticky, icon 291 | when 'UDP' then 292 | notify_udp name, title, message, priority, sticky 293 | else 294 | raise Growl::Error, "bug, unknown growl type #{@growl_type.inspect}" 295 | end 296 | 297 | self 298 | end 299 | 300 | def notify_gntp name, title, message, priority, sticky, icon = nil # :nodoc: 301 | growl = Growl::GNTP.new @host, @application_name 302 | growl.password = @password 303 | 304 | @notifications.each do |notification_name, details| 305 | growl.add_notification notification_name, *details 306 | end 307 | 308 | growl.register 309 | 310 | growl.notify name, title, message, priority, sticky 311 | end 312 | 313 | def notify_udp name, title, message, priority, sticky # :nodoc: 314 | all_notifications = @notifications.keys 315 | default_notifications = 316 | @notifications.select do |notification_name, (_, _, enabled)| 317 | enabled 318 | end.map do |notification_name,| 319 | notification_name 320 | end 321 | 322 | growl = Growl::UDP.new(@host, @application_name, all_notifications, 323 | default_notifications, @password) 324 | 325 | growl.notify name, title, message, priority, sticky 326 | end 327 | 328 | end 329 | 330 | require 'ruby-growl/gntp' 331 | require 'ruby-growl/udp' 332 | 333 | -------------------------------------------------------------------------------- /test/test_growl_udp.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'ruby-growl' 3 | 4 | class TestGrowlUDP < Minitest::Test 5 | 6 | def setup 7 | @growl = Growl::UDP.new "localhost", "ruby-growl test", 8 | ["ruby-growl Test Notification"] 9 | end 10 | 11 | def test_notify_priority 12 | assert_raises Growl::Error do 13 | @growl.notify "ruby-growl Test Notification", "", "", -3 14 | end 15 | 16 | -2.upto 2 do |priority| 17 | @growl.notify "ruby-growl Test Notification", 18 | "Priority #{priority}", 19 | "This message should have a priority set.", priority 20 | end 21 | 22 | assert_raises RuntimeError do 23 | @growl.notify "ruby-growl Test Notification", "", "", 3 24 | end 25 | 26 | rescue SystemCallError => e 27 | skip "#{e.class}: #{e.message}" 28 | end 29 | 30 | def test_notify_notify_type 31 | assert_raises Growl::Error do 32 | @growl.notify "bad notify type", "", "" 33 | end 34 | 35 | @growl.notify "ruby-growl Test Notification", "Empty", 36 | "This notification is empty." 37 | 38 | rescue SystemCallError => e 39 | skip "#{e.class}: #{e.message}" 40 | end 41 | 42 | def test_notify_sticky 43 | @growl.notify "ruby-growl Test Notification", "Sticky", 44 | "This notification should be sticky.", 0, true 45 | 46 | rescue SystemCallError => e 47 | skip "#{e.class}: #{e.message}" 48 | end 49 | 50 | def test_registration_packet 51 | @growl = Growl::UDP.new "localhost", "growlnotify", 52 | ["Command-Line Growl Notification"] 53 | 54 | expected = [ 55 | "01", "00", "00", "0b", "01", "01", "67", "72", # ......gr 56 | "6f", "77", "6c", "6e", "6f", "74", "69", "66", # owlnotif 57 | "79", "00", "1f", "43", "6f", "6d", "6d", "61", # y..Comma 58 | "6e", "64", "2d", "4c", "69", "6e", "65", "20", # nd-Line. 59 | "47", "72", "6f", "77", "6c", "20", "4e", "6f", # Growl.No 60 | "74", "69", "66", "69", "63", "61", "74", "69", # tificati 61 | "6f", "6e", "00", "57", "4a", "e3", "1b", "a5", # on.WJ... 62 | "49", "9c", "25", "3a", "be", "75", "5d", "e5", # I.%:.u]. 63 | "2c", "c9", "96" # ,.. 64 | ] 65 | 66 | packet = @growl.registration_packet 67 | 68 | assert_equal expected, util_hexes(packet) 69 | end 70 | 71 | def test_notification_packet 72 | @growl = Growl::UDP.new "localhost", "growlnotify", 73 | ["Command-Line Growl Notification"] 74 | 75 | expected = [ 76 | "01", "01", "00", "00", "00", "1f", "00", "00", # ........ 77 | "00", "02", "00", "0b", "43", "6f", "6d", "6d", # ....Comm 78 | "61", "6e", "64", "2d", "4c", "69", "6e", "65", # and-Line 79 | "20", "47", "72", "6f", "77", "6c", "20", "4e", # .Growl.N 80 | "6f", "74", "69", "66", "69", "63", "61", "74", # otificat 81 | "69", "6f", "6e", "68", "69", "67", "72", "6f", # ionhigro 82 | "77", "6c", "6e", "6f", "74", "69", "66", "79", # wlnotify 83 | "7f", "9c", "a0", "dd", "b6", "6b", "64", "75", # .....kdu 84 | "99", "c4", "4e", "7b", "f1", "b2", "5b", "e2", # ..N{..[. 85 | ] 86 | 87 | packet = @growl.notification_packet "Command-Line Growl Notification", 88 | "", "hi", 0, false 89 | 90 | assert_equal expected, util_hexes(packet) 91 | end 92 | 93 | def test_notification_packet_priority_negative_2 94 | @growl = Growl::UDP.new "localhost", "growlnotify", 95 | ["Command-Line Growl Notification"] 96 | 97 | expected = [ 98 | "01", "01", "0c", "00", "00", "1f", "00", "00", # ........ 99 | "00", "02", "00", "0b", "43", "6f", "6d", "6d", # ....Comm 100 | "61", "6e", "64", "2d", "4c", "69", "6e", "65", # and-Line 101 | "20", "47", "72", "6f", "77", "6c", "20", "4e", # .Growl.N 102 | "6f", "74", "69", "66", "69", "63", "61", "74", # otificat 103 | "69", "6f", "6e", "68", "69", "67", "72", "6f", # ionhigro 104 | "77", "6c", "6e", "6f", "74", "69", "66", "79", # wlnotify 105 | "64", "b4", "cc", "a8", "74", "ea", "30", "2d", 106 | "6e", "0f", "c1", "45", "b2", "b5", "58", "00" 107 | ] 108 | 109 | packet = @growl.notification_packet "Command-Line Growl Notification", 110 | "", "hi", -2, false 111 | 112 | assert_equal expected, util_hexes(packet) 113 | end 114 | 115 | def test_notification_packet_priority_negative_1 116 | @growl = Growl::UDP.new "localhost", "growlnotify", 117 | ["Command-Line Growl Notification"] 118 | 119 | expected = [ 120 | "01", "01", "0e", "00", "00", "1f", "00", "00", # ........ 121 | "00", "02", "00", "0b", "43", "6f", "6d", "6d", # ....Comm 122 | "61", "6e", "64", "2d", "4c", "69", "6e", "65", # and-Line 123 | "20", "47", "72", "6f", "77", "6c", "20", "4e", # .Growl.N 124 | "6f", "74", "69", "66", "69", "63", "61", "74", # otificat 125 | "69", "6f", "6e", "68", "69", "67", "72", "6f", # ionhigro 126 | "77", "6c", "6e", "6f", "74", "69", "66", "79", # wlnotify 127 | "19", "17", "9f", "84", "6d", "19", "c6", "04", 128 | "8e", "6d", "8d", "84", "05", "84", "5b" 129 | ] 130 | 131 | packet = @growl.notification_packet "Command-Line Growl Notification", 132 | "", "hi", -1, false 133 | 134 | assert_equal expected, util_hexes(packet) 135 | end 136 | 137 | def test_notification_packet_priority_1 138 | @growl = Growl::UDP.new "localhost", "growlnotify", 139 | ["Command-Line Growl Notification"] 140 | 141 | expected = [ 142 | "01", "01", "02", "00", "00", "1f", "00", "00", # ........ 143 | "00", "02", "00", "0b", "43", "6f", "6d", "6d", # ....Comm 144 | "61", "6e", "64", "2d", "4c", "69", "6e", "65", # and-Line 145 | "20", "47", "72", "6f", "77", "6c", "20", "4e", # .Growl.N 146 | "6f", "74", "69", "66", "69", "63", "61", "74", # otificat 147 | "69", "6f", "6e", "68", "69", "67", "72", "6f", # ionhigro 148 | "77", "6c", "6e", "6f", "74", "69", "66", "79", # wlnotify 149 | "03", "4d", "92", "cf", "5f", "6c", "c2", "4c", 150 | "4c", "f4", "f2", "b5", "24", "d3", "ae", "96" 151 | ] 152 | 153 | packet = @growl.notification_packet "Command-Line Growl Notification", 154 | "", "hi", 1, false 155 | 156 | packet = util_hexes(packet) 157 | 158 | assert_equal expected, packet 159 | end 160 | 161 | def test_notification_packet_priority_2 162 | @growl = Growl::UDP.new "localhost", "growlnotify", 163 | ["Command-Line Growl Notification"] 164 | 165 | expected = [ 166 | "01", "01", "04", "00", "00", "1f", "00", "00", # ........ 167 | "00", "02", "00", "0b", "43", "6f", "6d", "6d", # ....Comm 168 | "61", "6e", "64", "2d", "4c", "69", "6e", "65", # and-Line 169 | "20", "47", "72", "6f", "77", "6c", "20", "4e", # .Growl.N 170 | "6f", "74", "69", "66", "69", "63", "61", "74", # otificat 171 | "69", "6f", "6e", "68", "69", "67", "72", "6f", # ionhigro 172 | "77", "6c", "6e", "6f", "74", "69", "66", "79", # wlnotify 173 | "68", "91", "f7", "82", "20", "7f", "1b", "08", 174 | "98", "a3", "1b", "f6", "cc", "72", "39", "94" 175 | ] 176 | 177 | packet = @growl.notification_packet "Command-Line Growl Notification", 178 | "", "hi", 2, false 179 | 180 | assert_equal expected, util_hexes(packet) 181 | end 182 | 183 | def test_notification_packet_priority_sticky 184 | @growl = Growl::UDP.new "localhost", "growlnotify", 185 | ["Command-Line Growl Notification"] 186 | 187 | expected = [ 188 | "01", "01", "01", "00", "00", "1f", "00", "00", # ........ 189 | "00", "02", "00", "0b", "43", "6f", "6d", "6d", # ....Comm 190 | "61", "6e", "64", "2d", "4c", "69", "6e", "65", # and-Line 191 | "20", "47", "72", "6f", "77", "6c", "20", "4e", # .Growl.N 192 | "6f", "74", "69", "66", "69", "63", "61", "74", # otificat 193 | "69", "6f", "6e", "68", "69", "67", "72", "6f", # ionhigro 194 | "77", "6c", "6e", "6f", "74", "69", "66", "79", # wlnotify 195 | "94", "b7", "66", "74", "02", "ee", "78", "33", 196 | "c2", "a4", "54", "b2", "3b", "77", "5e", "27" 197 | ] 198 | 199 | packet = @growl.notification_packet "Command-Line Growl Notification", 200 | "", "hi", 0, true 201 | 202 | assert_equal expected, util_hexes(packet) 203 | end 204 | 205 | def test_socket 206 | @udp = Growl::UDP.allocate 207 | 208 | socket = @udp.socket "localhost" 209 | 210 | refute socket.getsockopt(:SOL_SOCKET, :SO_BROADCAST).bool 211 | end 212 | 213 | def test_socket_broadcast 214 | @udp = Growl::UDP.allocate 215 | 216 | socket = @udp.socket "255.255.255.255" 217 | 218 | assert socket.getsockopt(:SOL_SOCKET, :SO_BROADCAST).bool 219 | end 220 | 221 | def test_socket_subnet_broadcast 222 | skip "Socket.getifaddrs not supported" unless 223 | Socket.respond_to? :getifaddrs 224 | 225 | ifaddr = Socket.getifaddrs.find { |ifaddr| ifaddr.broadaddr } 226 | 227 | @udp = Growl::UDP.allocate 228 | 229 | socket = @udp.socket ifaddr.broadaddr.ip_address 230 | 231 | assert socket.getsockopt(:SOL_SOCKET, :SO_BROADCAST).bool 232 | end 233 | 234 | def util_hexes string 235 | if string.respond_to? :ord then 236 | string.scan(/./).map { |c| "%02x" % c.ord } 237 | else 238 | string.scan(/./).map { |c| "%02x" % c[0] } 239 | end 240 | end 241 | 242 | end 243 | 244 | -------------------------------------------------------------------------------- /lib/ruby-growl/gntp.rb: -------------------------------------------------------------------------------- 1 | require 'digest' 2 | require 'net/http' 3 | require 'openssl' 4 | require 'time' 5 | require 'uri' 6 | require 'uri/x_growl_resource' 7 | require 'uuid' 8 | 9 | ## 10 | # Growl Notification Transport Protocol 1.0 11 | # 12 | # In growl 1.3, GNTP replaced the UDP growl protocol from earlier versions. 13 | # GNTP has some new features beyond those supported in earlier versions 14 | # including: 15 | # 16 | # * Callback support 17 | # * Notification icons 18 | # * Encrypted notifications (not supported by growl at this time) 19 | # 20 | # Notably, subscription support is not implemented. 21 | # 22 | # This implementation is based on information from 23 | # http://www.growlforwindows.com/gfw/help/gntp.aspx 24 | 25 | class Growl::GNTP 26 | 27 | ## 28 | # Growl GNTP port 29 | 30 | PORT = 23053 31 | 32 | ## 33 | # Base GNTP error class 34 | 35 | class Error < Growl::Error; end 36 | 37 | ## 38 | # Raised when the server indicates a GNTP response error 39 | 40 | class ResponseError < Error 41 | 42 | ## 43 | # The headers from the error response 44 | 45 | attr_reader :headers 46 | 47 | ## 48 | # Creates a new error with +message+ from the response Error-Description 49 | # header and the full +headers+ 50 | 51 | def initialize message, headers 52 | super message 53 | 54 | @headers = headers 55 | end 56 | 57 | end 58 | 59 | ## 60 | # Raised when the original request was already received by this server 61 | 62 | class AlreadyProcessed < ResponseError; end 63 | 64 | ## 65 | # Raised when the server has an internal error 66 | 67 | class InternalServerError < ResponseError; end 68 | 69 | ## 70 | # Raised when the request was malformed 71 | 72 | class InvalidRequest < ResponseError; end 73 | 74 | ## 75 | # Raised when the server was unavailable or the client could not reach the 76 | # server 77 | 78 | class NetworkFailure < ResponseError; end 79 | 80 | ## 81 | # Raised when the request supplied a missing or wrong password or was 82 | # otherwise not authorized 83 | 84 | class NotAuthorized < ResponseError; end 85 | 86 | ## 87 | # Raised when the given notification type was registered but disabled 88 | 89 | class NotificationDisabled < ResponseError; end 90 | 91 | ## 92 | # Raised when the request is missing a required header 93 | 94 | class RequiredHeaderMissing < ResponseError; end 95 | 96 | ## 97 | # Raised when the server timed out waiting for the request to complete 98 | 99 | class TimedOut < ResponseError; end 100 | 101 | ## 102 | # Raised when the application is not registered to send notifications 103 | 104 | class UnknownApplication < ResponseError; end 105 | 106 | ## 107 | # Raised when the notification type was not registered 108 | 109 | class UnknownNotification < ResponseError; end 110 | 111 | ## 112 | # Raised when the request given was not a GNTP request 113 | 114 | class UnknownProtocol < ResponseError; end 115 | 116 | ## 117 | # Raised when the request used an unknown GNTP protocol version 118 | 119 | class UnknownProtocolVersion < ResponseError; end 120 | 121 | ERROR_MAP = { # :nodoc: 122 | 200 => Growl::GNTP::TimedOut, 123 | 201 => Growl::GNTP::NetworkFailure, 124 | 300 => Growl::GNTP::InvalidRequest, 125 | 301 => Growl::GNTP::UnknownProtocol, 126 | 302 => Growl::GNTP::UnknownProtocolVersion, 127 | 303 => Growl::GNTP::RequiredHeaderMissing, 128 | 400 => Growl::GNTP::NotAuthorized, 129 | 401 => Growl::GNTP::UnknownApplication, 130 | 402 => Growl::GNTP::UnknownNotification, 131 | 403 => Growl::GNTP::AlreadyProcessed, 132 | 404 => Growl::GNTP::NotificationDisabled, 133 | 500 => Growl::GNTP::InternalServerError, 134 | } 135 | 136 | ENCRYPTION_ALGORITHMS = { # :nodoc: 137 | 'DES' => 'DES-CBC', 138 | '3DES' => 'DES-EDE3-CBC', 139 | 'AES' => 'AES-192-CBC', 140 | } 141 | 142 | ## 143 | # Enables encryption for request bodies. 144 | # 145 | # Note that this does not appear to be supported in a released version of 146 | # growl. 147 | 148 | attr_accessor :encrypt 149 | 150 | ## 151 | # Sets the application icon 152 | # 153 | # The icon may be any image NSImage supports 154 | 155 | attr_accessor :icon 156 | 157 | ## 158 | # Objects used to generate UUIDs 159 | 160 | attr_accessor :uuid # :nodoc: 161 | 162 | ## 163 | # Hash of notifications registered with the server 164 | 165 | attr_reader :notifications 166 | 167 | ## 168 | # Password for authenticating and encrypting requests. If this is set, 169 | # authentication automatically takes place. 170 | 171 | attr_accessor :password 172 | 173 | ## 174 | # Creates a new Growl::GNTP instance that will communicate with +host+ and 175 | # has the given +application+ name, and will send the given 176 | # +notification_names+. 177 | # 178 | # If you wish to set icons or display names for notifications, use 179 | # add_notification instead of sending +notification_names+. 180 | 181 | def initialize host, application, notification_names = nil 182 | @host = host 183 | @application = application 184 | @notifications = {} 185 | @uuid = UUID.new 186 | 187 | notification_names.each do |name| 188 | add_notification name 189 | end if notification_names 190 | 191 | @encrypt = 'NONE' 192 | @password = nil 193 | @icon = nil 194 | end 195 | 196 | ## 197 | # Adds a notification with +name+ (internal) and +display_name+ (shown to 198 | # user). The +icon+ map be an image (anything NSImage supports) or a URI 199 | # (which is unsupported in growl 1.3). If the notification is +enabled+ it 200 | # will be displayed by default. 201 | 202 | def add_notification name, display_name = nil, icon = nil, enabled = true 203 | @notifications[name] = display_name, icon, enabled 204 | end 205 | 206 | ## 207 | # Creates a symmetric encryption cipher for +key+ based on the #encrypt 208 | # method. 209 | 210 | def cipher key, iv = nil 211 | algorithm = ENCRYPTION_ALGORITHMS[@encrypt] 212 | 213 | raise Error, "unknown GNTP encryption mode #{@encrypt}" unless algorithm 214 | 215 | cipher = OpenSSL::Cipher.new algorithm 216 | cipher.encrypt 217 | 218 | cipher.key = key 219 | 220 | if iv then 221 | cipher.iv = iv 222 | else 223 | iv = cipher.random_iv 224 | end 225 | 226 | return cipher, iv 227 | end 228 | 229 | ## 230 | # Creates a TCP connection to the chosen #host 231 | 232 | def connect 233 | TCPSocket.new @host, PORT 234 | end 235 | 236 | ## 237 | # Returns an encryption key, authentication hash and random salt for the 238 | # given hash +algorithm+. 239 | 240 | def key_hash algorithm 241 | key = @password.dup.force_encoding Encoding::BINARY 242 | salt = self.salt 243 | basis = "#{key}#{salt}" 244 | 245 | key = algorithm.digest basis 246 | 247 | hash = algorithm.hexdigest key 248 | 249 | return key, hash, salt 250 | end 251 | 252 | ## 253 | # Sends a +notification+ with the given +title+ and +text+. The +priority+ 254 | # may be between -2 (lowest) and 2 (highest). +sticky+ will indicate the 255 | # notification must be manually dismissed. +callback_url+ is supposed to 256 | # open the given URL on the server's web browser when clicked, but I haven't 257 | # seen this work. 258 | # 259 | # If a block is given, it is called when the notification is clicked, times 260 | # out, or is manually dismissed. 261 | 262 | def notify(notification, title, text = nil, priority = 0, sticky = false, 263 | coalesce_id = nil, callback_url = nil, &block) 264 | 265 | raise ArgumentError, 'provide either a url or a block for callbacks, ' \ 266 | 'not both' if block and callback_url 267 | 268 | callback = callback_url || block_given? 269 | 270 | packet = packet_notify(notification, title, text, 271 | priority, sticky, coalesce_id, callback) 272 | 273 | send packet, &block 274 | end 275 | 276 | ## 277 | # Creates a +type+ packet (such as REGISTER or NOTIFY) with the given 278 | # +headers+ and +resources+. Handles authentication and encryption of the 279 | # packet. 280 | 281 | def packet type, headers, resources = {} 282 | packet = [] 283 | 284 | body = [] 285 | body << "Application-Name: #{@application}" 286 | body << "Origin-Software-Name: ruby-growl" 287 | body << "Origin-Software-Version: #{Growl::VERSION}" 288 | body << "Origin-Platform-Name: ruby" 289 | body << "Origin-Platform-Version: #{RUBY_VERSION}" 290 | body << "Connection: close" 291 | body.concat headers 292 | body << nil 293 | body = body.join "\r\n" 294 | 295 | if @password then 296 | digest = Digest::SHA512 297 | key, hash, salt = key_hash digest 298 | key_info = "SHA512:#{hash}.#{Digest.hexencode salt}" 299 | end 300 | 301 | if @encrypt == 'NONE' then 302 | packet << ["GNTP/1.0", type, "NONE", key_info].compact.join(' ') 303 | packet << body.force_encoding("ASCII-8BIT") 304 | else 305 | encipher, iv = cipher key 306 | 307 | encrypt_info = "#{@encrypt}:#{Digest.hexencode iv}" 308 | 309 | packet << "GNTP/1.0 #{type} #{encrypt_info} #{key_info}" 310 | 311 | encrypted = encipher.update body 312 | encrypted << encipher.final 313 | 314 | packet << encrypted 315 | end 316 | 317 | resources.each do |id, data| 318 | if iv then 319 | encipher, = cipher key, iv 320 | 321 | encrypted = encipher.update data 322 | encrypted << encipher.final 323 | 324 | data = encrypted 325 | end 326 | 327 | packet << "Identifier: #{id}" 328 | packet << "Length: #{data.length}" 329 | packet << nil 330 | packet << data 331 | packet << nil 332 | end 333 | 334 | packet << nil 335 | packet << nil 336 | 337 | packet.join "\r\n" 338 | end 339 | 340 | ## 341 | # Creates a notify packet. See #notify for parameter details. 342 | 343 | def packet_notify(notification, title, text, priority, sticky, coalesce_id, 344 | callback) 345 | raise ArgumentError, "invalid priority level #{priority}" unless 346 | priority >= -2 and priority <= 2 347 | 348 | resources = {} 349 | _, icon, = @notifications[notification] 350 | 351 | if URI === icon then 352 | icon_uri = icon 353 | elsif icon then 354 | id = @uuid.generate 355 | 356 | resources[id] = icon 357 | end 358 | 359 | headers = [] 360 | headers << "Notification-ID: #{@uuid.generate}" 361 | headers << "Notification-Coalescing-ID: #{coalesce_id}" if coalesce_id 362 | headers << "Notification-Name: #{notification}" 363 | headers << "Notification-Title: #{title}" 364 | headers << "Notification-Text: #{text}" if text 365 | headers << "Notification-Priority: #{priority}" if priority.nonzero? 366 | headers << "Notification-Sticky: True" if sticky 367 | headers << "Notification-Icon: #{icon}" if icon_uri 368 | headers << "Notification-Icon: x-growl-resource://#{id}" if id 369 | 370 | if callback then 371 | headers << "Notification-Callback-Context: context" 372 | headers << "Notification-Callback-Context-Type: type" 373 | headers << "Notification-Callback-Target: #{callback}" unless 374 | callback == true 375 | end 376 | 377 | packet :NOTIFY, headers, resources 378 | end 379 | 380 | ## 381 | # Creates a registration packet 382 | 383 | def packet_register 384 | resources = {} 385 | 386 | headers = [] 387 | 388 | case @icon 389 | when URI then 390 | headers << "Application-Icon: #{@icon}" 391 | when NilClass then 392 | # ignore 393 | else 394 | app_icon_id = @uuid.generate 395 | 396 | headers << "Application-Icon: x-growl-resource://#{app_icon_id}" 397 | 398 | resources[app_icon_id] = @icon 399 | end 400 | 401 | headers << "Notifications-Count: #{@notifications.length}" 402 | headers << nil 403 | 404 | @notifications.each do |name, (display_name, icon, enabled)| 405 | headers << "Notification-Name: #{name}" 406 | headers << "Notification-Display-Name: #{display_name}" if display_name 407 | headers << "Notification-Enabled: true" if enabled 408 | 409 | # This does not appear to be used by growl so ruby-growl sends the 410 | # icon with every notification. 411 | if URI === icon then 412 | headers << "Notification-Icon: #{icon}" 413 | elsif icon then 414 | id = @uuid.generate 415 | 416 | headers << "Notification-Icon: x-growl-resource://#{id}" 417 | 418 | resources[id] = icon 419 | end 420 | 421 | headers << nil 422 | end 423 | 424 | headers.pop # remove trailing nil 425 | 426 | packet :REGISTER, headers, resources 427 | end 428 | 429 | ## 430 | # Parses the +value+ for +header+ into the correct ruby type 431 | 432 | def parse_header header, value 433 | return [header, nil] if value == '(null)' 434 | 435 | case header 436 | when 'Notification-Enabled', 437 | 'Notification-Sticky' then 438 | if value =~ /^(true|yes)$/i then 439 | [header, true] 440 | elsif value =~ /^(false|no)$/i then 441 | [header, false] 442 | else 443 | [header, value] 444 | end 445 | when 'Notification-Callback-Timestamp' then 446 | [header, Time.parse(value)] 447 | when 'Error-Code', 448 | 'Notifications-Count', 449 | 'Notifications-Priority', 450 | 'Subscriber-Port', 451 | 'Subscription-TTL' then 452 | [header, value.to_i] 453 | when 'Application-Name', 454 | 'Error-Description', 455 | 'Notification-Callback-Context', 456 | 'Notification-Callback-Context-Type', 457 | 'Notification-Callback-Target', 458 | 'Notification-Coalescing-ID', 459 | 'Notification-Display-Name', 460 | 'Notification-ID', 461 | 'Notification-Name', 462 | 'Notification-Text', 463 | 'Notification-Title', 464 | 'Origin-Machine-Name', 465 | 'Origin-Platform-Name', 466 | 'Origin-Platform-Version', 467 | 'Origin-Software-Version', 468 | 'Origin-Sofware-Name', 469 | 'Subscriber-ID', 470 | 'Subscriber-Name' then 471 | value.force_encoding Encoding::UTF_8 472 | 473 | [header, value] 474 | when 'Application-Icon', 475 | 'Notification-Icon' then 476 | value = URI value 477 | [header, value] 478 | else 479 | [header, value] 480 | end 481 | end 482 | 483 | ## 484 | # Receives and handles the response +packet+ from the server and either 485 | # raises an error or returns a headers Hash. 486 | 487 | def receive packet 488 | $stderr.puts "> #{packet.gsub(/\r\n/, "\n> ")}" if $DEBUG 489 | 490 | packet = packet.strip.split "\r\n" 491 | 492 | info = packet.shift 493 | info =~ %r%^GNTP/([\d.]+) (\S+) (\S+)$% 494 | 495 | version = $1 496 | message = $2 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 | 554 | -------------------------------------------------------------------------------- /test/test_growl_gntp.rb: -------------------------------------------------------------------------------- 1 | # coding: UTF-8 2 | 3 | require 'minitest/autorun' 4 | require 'ruby-growl' 5 | require 'ruby-growl/ruby_logo' 6 | require 'stringio' 7 | 8 | class TestGrowlGNTP < Minitest::Test 9 | 10 | class Socket 11 | attr_reader :_input, :_output 12 | 13 | def initialize *a 14 | @_input = StringIO.new 15 | @_output = StringIO.new 16 | end 17 | 18 | def gets separator 19 | @_input.gets separator 20 | end 21 | 22 | def read *a 23 | @_input.read(*a) 24 | end 25 | 26 | def write data 27 | @_output.write data 28 | end 29 | 30 | def _input= data 31 | @_input.write data 32 | @_input.rewind 33 | end 34 | end 35 | 36 | class UUID 37 | def generate() 4 end 38 | end 39 | 40 | def setup 41 | @gntp = Growl::GNTP.new 'localhost', 'test-app' 42 | @gntp.uuid = UUID.new 43 | 44 | rocket_path = File.join 'test', 'rocketAlpha.jpg' 45 | rocket_path = File.expand_path rocket_path 46 | 47 | @jpg_data = File.read rocket_path, mode: 'rb' 48 | @jpg_url = "file://#{rocket_path}" 49 | end 50 | 51 | def test_add_notification 52 | @gntp.add_notification 'test', 'Test Notification', @jpg_url, true 53 | 54 | expected = { 'test' => ['Test Notification', @jpg_url, true] } 55 | 56 | assert_equal expected, @gntp.notifications 57 | end 58 | 59 | def test_cipher_des 60 | @gntp.encrypt = 'DES' 61 | key = "P>\a\x8AB\x01\xDF\xCET\x0F\xC7\xC9\xBC_^\xC0" 62 | 63 | cipher, iv = @gntp.cipher key 64 | 65 | assert_equal 'DES-CBC', cipher.name 66 | assert_equal 8, cipher.iv_len 67 | assert_equal 8, cipher.key_len 68 | 69 | assert_kind_of String, iv 70 | 71 | assert_endecrypt cipher, key, iv 72 | end 73 | 74 | def test_cipher_iv 75 | @gntp.encrypt = 'AES' 76 | input_iv = 'junkjunkjunkjunk' 77 | 78 | key = "\xF8\x93\xD4\xEB)u(\x06" \ 79 | "\x92\x88|)\x00\x97\xC73" \ 80 | "\x16/\xF3o\xB9@\xBA\x9D" 81 | 82 | cipher, iv = @gntp.cipher key, input_iv 83 | 84 | assert_equal 'AES-192-CBC', cipher.name 85 | assert_equal 24, cipher.key_len 86 | 87 | assert_equal input_iv, iv 88 | 89 | assert_endecrypt cipher, key, iv 90 | end 91 | 92 | def test_cipher_triple_des 93 | @gntp.encrypt = '3DES' 94 | key = "\xF8\x93\xD4\xEB)u(\x06" \ 95 | "\x92\x88|)\x00\x97\xC73" \ 96 | "\x16/\xF3o\xB9@\xBA\x9D" 97 | 98 | cipher, iv = @gntp.cipher key 99 | 100 | assert_equal 'DES-EDE3-CBC', cipher.name 101 | assert_equal 8, cipher.iv_len 102 | assert_equal 24, cipher.key_len 103 | 104 | assert_kind_of String, iv 105 | 106 | assert_endecrypt cipher, key, iv 107 | end 108 | 109 | def test_cipher_aes 110 | @gntp.encrypt = 'AES' 111 | key = "\xF8\x93\xD4\xEB)u(\x06" \ 112 | "\x92\x88|)\x00\x97\xC73" \ 113 | "\x16/\xF3o\xB9@\xBA\x9D" 114 | 115 | cipher, iv = @gntp.cipher key 116 | 117 | assert_equal 'AES-192-CBC', cipher.name 118 | assert_equal 16, cipher.iv_len 119 | assert_equal 24, cipher.key_len 120 | 121 | assert_kind_of String, iv 122 | 123 | assert_endecrypt cipher, key, iv 124 | end 125 | 126 | def test_key_hash_md5 127 | stub_salt 128 | @gntp.password = 'πassword' 129 | algorithm = Digest::MD5 130 | 131 | key, hash, = @gntp.key_hash algorithm 132 | 133 | expected = [ 134 | 80, 62, 7, 138, 66, 1, 223, 206, 135 | 84, 15, 199, 201, 188, 95, 94, 192, 136 | ] 137 | 138 | assert_equal expected, key.unpack('C*'), 'key' 139 | 140 | expected = 'c552e68e5d86772487f6014b02cb4a14' 141 | 142 | assert_equal expected, hash, 'hash' 143 | end 144 | 145 | def test_key_hash_sha1 146 | stub_salt 147 | @gntp.password = 'πassword' 148 | algorithm = Digest::SHA1 149 | 150 | key, hash, = @gntp.key_hash algorithm 151 | 152 | expected = [ 153 | 206, 111, 53, 40, 168, 195, 0, 193, 154 | 209, 5, 102, 197, 114, 212, 228, 64, 155 | 38, 168, 23, 187 156 | ] 157 | 158 | assert_equal expected, key.unpack('C*'), 'key' 159 | 160 | expected = '03247e7e5b3ae9033dba23cf4637023542bc10d3' 161 | 162 | assert_equal expected, hash, 'hash' 163 | end 164 | 165 | def test_key_hash_sha256 166 | stub_salt 167 | @gntp.password = 'πassword' 168 | algorithm = Digest::SHA256 169 | 170 | key, hash, = @gntp.key_hash algorithm 171 | 172 | expected = [ 173 | 248, 147, 212, 235, 41, 117, 40, 6, 174 | 146, 136, 124, 41, 0, 151, 199, 51, 175 | 22, 47, 243, 111, 185, 64, 186, 157, 176 | 227, 141, 213, 37, 127, 20, 155, 130 177 | ] 178 | 179 | assert_equal expected, key.unpack('C*'), 'key' 180 | 181 | expected = '88b55cd37083d87e' \ 182 | 'cf79de12afe1c1b8' \ 183 | '8300c0d84c6ac35b' \ 184 | 'cc6227c47a55087f' 185 | 186 | assert_equal expected, hash, 'hash' 187 | end 188 | 189 | def test_key_hash_sha512 190 | stub_salt 191 | @gntp.password = 'πassword' 192 | algorithm = Digest::SHA512 193 | 194 | key, hash, = @gntp.key_hash algorithm 195 | 196 | expected = [ 197 | 134, 105, 63, 2, 240, 31, 36, 158, 198 | 20, 198, 246, 227, 240, 111, 158, 3, 199 | 37, 23, 1, 129, 27, 189, 68, 110, 200 | 105, 213, 90, 0, 23, 146, 218, 69, 201 | 253, 4, 57, 3, 152, 101, 22, 55, 202 | 89, 99, 133, 21, 95, 238, 181, 5, 203 | 67, 87, 108, 15, 128, 190, 137, 150, 204 | 151, 83, 245, 219, 21, 251, 95, 182, 205 | ] 206 | 207 | assert_equal expected, key.unpack('C*'), 'key' 208 | 209 | expected = '2407322ff8b1f13c' \ 210 | '75774ea8a954c74c' \ 211 | 'fb5138813f49a7c5' \ 212 | '5e230cfad7426c42' \ 213 | 'cc4771262331a559' \ 214 | '2ddc243462d7f6f8' \ 215 | '9ebd7581cb52c451' \ 216 | '7648834d624c3c60' 217 | 218 | assert_equal expected, hash, 'hash' 219 | end 220 | 221 | def test_notify 222 | stub_socket "GNTP/1.0 -OK NONE\r\n" \ 223 | "Response-Action: NOTIFY\r\n" \ 224 | "Notification-ID: (null)\r\n\r\n\r\n" 225 | 226 | response = @gntp.notify 'test', 'title', 'message', 2, true 227 | 228 | expected = { 229 | 'Response-Action' => 'NOTIFY', 230 | 'Notification-ID' => nil, 231 | } 232 | 233 | assert_equal expected, response 234 | end 235 | 236 | def test_notify_callback 237 | callback_result = nil 238 | stub_socket <<-STREAM 239 | GNTP/1.0 -OK NONE\r 240 | Response-Action: NOTIFY\r 241 | Notification-ID: 4\r 242 | \r 243 | \r 244 | \r 245 | GNTP/1.0 -CALLBACK NONE\r 246 | Response-Action: NOTIFY\r 247 | Notification-ID: 4\r 248 | Notification-Callback-Result: CLICKED\r 249 | Notification-Callback-Timestamp: 2012-03-28\r 250 | Notification-Callback-Context: context\r 251 | Notification-Callback-Context-Type: type\r 252 | Application-Name: test\r 253 | \r 254 | \r 255 | STREAM 256 | 257 | response = @gntp.notify 'test', 'title', 'message' do |result| 258 | callback_result = result 259 | end 260 | 261 | expected = { 262 | 'Response-Action' => 'NOTIFY', 263 | 'Notification-ID' => '4', 264 | 'Notification-Callback-Result' => 'CLICKED', 265 | 'Notification-Callback-Timestamp' => Time.parse('2012-03-28'), 266 | 'Notification-Callback-Context' => 'context', 267 | 'Notification-Callback-Context-Type' => 'type', 268 | 'Application-Name' => 'test' 269 | } 270 | 271 | assert_equal expected, callback_result 272 | 273 | expected = { 274 | 'Response-Action' => 'NOTIFY', 275 | 'Notification-ID' => '4', 276 | } 277 | 278 | assert_equal expected, response 279 | end 280 | 281 | def test_notify_callback_with_uri 282 | e = assert_raises ArgumentError do 283 | @gntp.notify 'test', 'title', 'message', 0, false, nil, 'uri' do end 284 | end 285 | 286 | assert_equal 'provide either a url or a block for callbacks, not both', 287 | e.message 288 | end 289 | 290 | def test_notify_coalesce 291 | stub_socket "GNTP/1.0 -OK NONE\r\n" \ 292 | "Response-Action: NOTIFY\r\n" \ 293 | "Notification-ID: (null)\r\n\r\n\r\n" 294 | 295 | response = @gntp.notify 'test', 'title', 'message', 0, false, 'some_id' 296 | 297 | expected = { 298 | 'Response-Action' => 'NOTIFY', 299 | 'Notification-ID' => nil, 300 | } 301 | 302 | assert_equal expected, response 303 | end 304 | 305 | def test_packet 306 | expected = <<-EXPECTED 307 | GNTP/1.0 REGISTER NONE\r 308 | Application-Name: test-app\r 309 | Origin-Software-Name: ruby-growl\r 310 | Origin-Software-Version: #{Growl::VERSION}\r 311 | Origin-Platform-Name: ruby\r 312 | Origin-Platform-Version: #{RUBY_VERSION}\r 313 | Connection: close\r 314 | Foo: bar\r 315 | \r 316 | \r 317 | EXPECTED 318 | 319 | assert_equal expected, @gntp.packet('REGISTER', ["Foo: bar"]) 320 | end 321 | 322 | def test_packet_encrypt_des 323 | @gntp.encrypt = 'DES' 324 | @gntp.password = 'password' 325 | 326 | packet = @gntp.packet 'REGISTER', ["Foo: bar"] 327 | 328 | info, body = packet.split "\r\n", 2 329 | 330 | _, _, algorithm_info, key_info = info.split ' ' 331 | 332 | cipher, iv = algorithm_info.split ':' 333 | 334 | assert_equal 'DES', cipher 335 | 336 | iv = [iv].pack 'H*' 337 | 338 | cipher = OpenSSL::Cipher.new Growl::GNTP::ENCRYPTION_ALGORITHMS[cipher] 339 | 340 | assert_equal 'DES-CBC', cipher.name 341 | 342 | _, salt = key_info.split '.', 2 343 | 344 | salt = [salt].pack 'H*' 345 | 346 | key = Digest::SHA512.digest "password#{salt}" 347 | 348 | body = body.chomp "\r\n\r\n" 349 | 350 | decrypted = decrypt cipher, key, iv, body 351 | 352 | expected = <<-EXPECTED 353 | Application-Name: test-app\r 354 | Origin-Software-Name: ruby-growl\r 355 | Origin-Software-Version: #{Growl::VERSION}\r 356 | Origin-Platform-Name: ruby\r 357 | Origin-Platform-Version: #{RUBY_VERSION}\r 358 | Connection: close\r 359 | Foo: bar\r 360 | EXPECTED 361 | 362 | assert_equal expected, decrypted 363 | end 364 | 365 | def test_packet_encrypt_3des 366 | @gntp.encrypt = '3DES' 367 | @gntp.password = 'password' 368 | 369 | packet = @gntp.packet 'REGISTER', ["Foo: bar"] 370 | 371 | info, body = packet.split "\r\n", 2 372 | 373 | _, _, algorithm_info, key_info = info.split ' ' 374 | 375 | cipher, iv = algorithm_info.split ':' 376 | 377 | assert_equal '3DES', cipher 378 | 379 | iv = [iv].pack 'H*' 380 | 381 | cipher = OpenSSL::Cipher.new Growl::GNTP::ENCRYPTION_ALGORITHMS[cipher] 382 | 383 | assert_equal 'DES-EDE3-CBC', cipher.name 384 | 385 | _, salt = key_info.split '.', 2 386 | 387 | salt = [salt].pack 'H*' 388 | 389 | key = Digest::SHA512.digest "password#{salt}" 390 | 391 | body = body.chomp "\r\n\r\n" 392 | 393 | decrypted = decrypt cipher, key, iv, body 394 | 395 | expected = <<-EXPECTED 396 | Application-Name: test-app\r 397 | Origin-Software-Name: ruby-growl\r 398 | Origin-Software-Version: #{Growl::VERSION}\r 399 | Origin-Platform-Name: ruby\r 400 | Origin-Platform-Version: #{RUBY_VERSION}\r 401 | Connection: close\r 402 | Foo: bar\r 403 | EXPECTED 404 | 405 | assert_equal expected, decrypted 406 | end 407 | 408 | def test_packet_encrypt_aes 409 | @gntp.encrypt = 'AES' 410 | @gntp.password = 'password' 411 | 412 | packet = @gntp.packet 'REGISTER', ["Foo: bar"] 413 | 414 | info, body = packet.split "\r\n", 2 415 | 416 | _, _, algorithm_info, key_info = info.split ' ' 417 | 418 | cipher, iv = algorithm_info.split ':' 419 | 420 | assert_equal 'AES', cipher 421 | 422 | iv = [iv].pack 'H*' 423 | 424 | cipher = OpenSSL::Cipher.new Growl::GNTP::ENCRYPTION_ALGORITHMS[cipher] 425 | 426 | assert_equal 'AES-192-CBC', cipher.name 427 | 428 | _, salt = key_info.split '.', 2 429 | 430 | salt = [salt].pack 'H*' 431 | 432 | key = Digest::SHA512.digest "password#{salt}" 433 | 434 | body = body.chomp "\r\n\r\n" 435 | 436 | decrypted = decrypt cipher, key, iv, body 437 | 438 | expected = <<-EXPECTED 439 | Application-Name: test-app\r 440 | Origin-Software-Name: ruby-growl\r 441 | Origin-Software-Version: #{Growl::VERSION}\r 442 | Origin-Platform-Name: ruby\r 443 | Origin-Platform-Version: #{RUBY_VERSION}\r 444 | Connection: close\r 445 | Foo: bar\r 446 | EXPECTED 447 | 448 | assert_equal expected, decrypted 449 | end 450 | 451 | def test_packet_encrypt_aes_icon 452 | @gntp.encrypt = 'AES' 453 | @gntp.password = 'password' 454 | 455 | packet = @gntp.packet 'REGISTER', ["Foo: bar"], { 'icon' => @jpg_data } 456 | 457 | info, body = packet.split "\r\n", 2 458 | 459 | _, _, algorithm_info, key_info = info.split ' ' 460 | 461 | cipher, iv = algorithm_info.split ':' 462 | 463 | assert_equal 'AES', cipher 464 | 465 | iv = [iv].pack 'H*' 466 | 467 | cipher = OpenSSL::Cipher.new Growl::GNTP::ENCRYPTION_ALGORITHMS[cipher] 468 | 469 | assert_equal 'AES-192-CBC', cipher.name 470 | 471 | _, salt = key_info.split '.', 2 472 | 473 | salt = [salt].pack 'H*' 474 | 475 | key = Digest::SHA512.digest "password#{salt}" 476 | 477 | body = body.chomp "\r\n\r\n" 478 | 479 | end_of_headers = body.index "\r\nIdentifier: " 480 | headers = body.slice! 0, end_of_headers 481 | 482 | decrypted = decrypt cipher, key, iv, headers 483 | 484 | expected = <<-EXPECTED 485 | Application-Name: test-app\r 486 | Origin-Software-Name: ruby-growl\r 487 | Origin-Software-Version: #{Growl::VERSION}\r 488 | Origin-Platform-Name: ruby\r 489 | Origin-Platform-Version: #{RUBY_VERSION}\r 490 | Connection: close\r 491 | Foo: bar\r 492 | EXPECTED 493 | 494 | assert_equal expected, decrypted 495 | 496 | body =~ /Length: (\d+)\r\n\r\n/ 497 | 498 | data_length = $1.to_i 499 | data_offset = $`.length + $&.length 500 | 501 | data = body[data_offset, data_length] 502 | 503 | decrypted = decrypt cipher, key, iv, data 504 | 505 | assert_equal @jpg_data, decrypted 506 | end 507 | 508 | def test_packet_hash 509 | @gntp.password = 'password' 510 | 511 | packet = @gntp.packet 'REGISTER', ["Foo: bar"] 512 | 513 | info, body = packet.split "\r\n", 2 514 | 515 | _, _, algorithm_info, key_info = info.split ' ' 516 | 517 | assert_equal 'NONE', algorithm_info 518 | 519 | key_info =~ /:(.*)\./ 520 | 521 | key_hash = $1 522 | salt = $' 523 | 524 | salt = [salt].pack 'H*' 525 | 526 | expected_key = Digest::SHA512.digest "password#{salt}" 527 | expected_key_hash = Digest::SHA512.hexdigest expected_key 528 | 529 | assert_equal expected_key_hash, key_hash 530 | 531 | expected = <<-EXPECTED 532 | Application-Name: test-app\r 533 | Origin-Software-Name: ruby-growl\r 534 | Origin-Software-Version: #{Growl::VERSION}\r 535 | Origin-Platform-Name: ruby\r 536 | Origin-Platform-Version: #{RUBY_VERSION}\r 537 | Connection: close\r 538 | Foo: bar\r 539 | \r 540 | \r 541 | EXPECTED 542 | 543 | assert_equal expected, body 544 | end 545 | 546 | def test_packet_icon_utf_8 547 | packet = @gntp.packet 'REGISTER', ['Foo: π'], 1 => Growl::RUBY_LOGO_PNG 548 | 549 | assert_equal Encoding::BINARY, packet.encoding 550 | end 551 | 552 | def test_packet_notify 553 | expected = <<-EXPECTED 554 | GNTP/1.0 NOTIFY NONE\r 555 | Application-Name: test-app\r 556 | Origin-Software-Name: ruby-growl\r 557 | Origin-Software-Version: #{Growl::VERSION}\r 558 | Origin-Platform-Name: ruby\r 559 | Origin-Platform-Version: #{RUBY_VERSION}\r 560 | Connection: close\r 561 | Notification-ID: 4\r 562 | Notification-Name: test-note\r 563 | Notification-Title: title\r 564 | \r 565 | \r 566 | EXPECTED 567 | 568 | assert_equal expected, @gntp.packet_notify('test-note', 'title', 569 | nil, 0, false, nil, nil) 570 | end 571 | 572 | def test_packet_notify_callback 573 | expected = <<-EXPECTED 574 | GNTP/1.0 NOTIFY NONE\r 575 | Application-Name: test-app\r 576 | Origin-Software-Name: ruby-growl\r 577 | Origin-Software-Version: #{Growl::VERSION}\r 578 | Origin-Platform-Name: ruby\r 579 | Origin-Platform-Version: #{RUBY_VERSION}\r 580 | Connection: close\r 581 | Notification-ID: 4\r 582 | Notification-Name: test-note\r 583 | Notification-Title: title\r 584 | Notification-Callback-Context: context\r 585 | Notification-Callback-Context-Type: type\r 586 | \r 587 | \r 588 | EXPECTED 589 | 590 | result = @gntp.packet_notify 'test-note', 'title', nil, 0, false, nil, true 591 | 592 | assert_equal expected, result 593 | end 594 | 595 | def test_packet_notify_callback_url 596 | expected = <<-EXPECTED 597 | GNTP/1.0 NOTIFY NONE\r 598 | Application-Name: test-app\r 599 | Origin-Software-Name: ruby-growl\r 600 | Origin-Software-Version: #{Growl::VERSION}\r 601 | Origin-Platform-Name: ruby\r 602 | Origin-Platform-Version: #{RUBY_VERSION}\r 603 | Connection: close\r 604 | Notification-ID: 4\r 605 | Notification-Name: test-note\r 606 | Notification-Title: title\r 607 | Notification-Callback-Context: context\r 608 | Notification-Callback-Context-Type: type\r 609 | Notification-Callback-Target: http://example\r 610 | \r 611 | \r 612 | EXPECTED 613 | 614 | assert_equal expected, @gntp.packet_notify('test-note', 'title', 615 | nil, 0, false, nil, 616 | 'http://example') 617 | end 618 | 619 | def test_packet_notify_coalesce 620 | expected = <<-EXPECTED 621 | GNTP/1.0 NOTIFY NONE\r 622 | Application-Name: test-app\r 623 | Origin-Software-Name: ruby-growl\r 624 | Origin-Software-Version: #{Growl::VERSION}\r 625 | Origin-Platform-Name: ruby\r 626 | Origin-Platform-Version: #{RUBY_VERSION}\r 627 | Connection: close\r 628 | Notification-ID: 4\r 629 | Notification-Coalescing-ID: 3\r 630 | Notification-Name: test-note\r 631 | Notification-Title: title\r 632 | \r 633 | \r 634 | EXPECTED 635 | 636 | assert_equal expected, @gntp.packet_notify('test-note', 'title', 637 | nil, 0, false, 3, nil) 638 | end 639 | 640 | def test_packet_notify_description 641 | expected = <<-EXPECTED 642 | GNTP/1.0 NOTIFY NONE\r 643 | Application-Name: test-app\r 644 | Origin-Software-Name: ruby-growl\r 645 | Origin-Software-Version: #{Growl::VERSION}\r 646 | Origin-Platform-Name: ruby\r 647 | Origin-Platform-Version: #{RUBY_VERSION}\r 648 | Connection: close\r 649 | Notification-ID: 4\r 650 | Notification-Name: test-note\r 651 | Notification-Title: title\r 652 | Notification-Text: message\r 653 | \r 654 | \r 655 | EXPECTED 656 | 657 | assert_equal expected, @gntp.packet_notify('test-note', 'title', 'message', 658 | 0, false, nil, nil) 659 | end 660 | 661 | def test_packet_notify_icon 662 | @gntp.add_notification 'test-note', nil, @jpg_url 663 | 664 | expected = <<-EXPECTED 665 | GNTP/1.0 NOTIFY NONE\r 666 | Application-Name: test-app\r 667 | Origin-Software-Name: ruby-growl\r 668 | Origin-Software-Version: #{Growl::VERSION}\r 669 | Origin-Platform-Name: ruby\r 670 | Origin-Platform-Version: #{RUBY_VERSION}\r 671 | Connection: close\r 672 | Notification-ID: 4\r 673 | Notification-Name: test-note\r 674 | Notification-Title: title\r 675 | Notification-Icon: x-growl-resource://4\r 676 | \r 677 | Identifier: 4\r 678 | Length: #{@jpg_url.size}\r 679 | \r 680 | #{@jpg_url}\r 681 | \r 682 | \r 683 | EXPECTED 684 | 685 | assert_equal expected, @gntp.packet_notify('test-note', 'title', 686 | nil, 0, false, nil, nil) 687 | end 688 | 689 | def test_packet_notify_icon_uri 690 | uri = URI 'http://example/icon.png' 691 | @gntp.add_notification 'test-note', nil, uri 692 | 693 | expected = <<-EXPECTED 694 | GNTP/1.0 NOTIFY NONE\r 695 | Application-Name: test-app\r 696 | Origin-Software-Name: ruby-growl\r 697 | Origin-Software-Version: #{Growl::VERSION}\r 698 | Origin-Platform-Name: ruby\r 699 | Origin-Platform-Version: #{RUBY_VERSION}\r 700 | Connection: close\r 701 | Notification-ID: 4\r 702 | Notification-Name: test-note\r 703 | Notification-Title: title\r 704 | Notification-Icon: http://example/icon.png\r 705 | \r 706 | \r 707 | EXPECTED 708 | 709 | assert_equal expected, @gntp.packet_notify('test-note', 'title', 710 | nil, 0, false, nil, nil) 711 | end 712 | 713 | def test_packet_notify_priority 714 | expected = <<-EXPECTED 715 | GNTP/1.0 NOTIFY NONE\r 716 | Application-Name: test-app\r 717 | Origin-Software-Name: ruby-growl\r 718 | Origin-Software-Version: #{Growl::VERSION}\r 719 | Origin-Platform-Name: ruby\r 720 | Origin-Platform-Version: #{RUBY_VERSION}\r 721 | Connection: close\r 722 | Notification-ID: 4\r 723 | Notification-Name: test-note\r 724 | Notification-Title: title\r 725 | Notification-Priority: 2\r 726 | \r 727 | \r 728 | EXPECTED 729 | 730 | assert_equal expected, @gntp.packet_notify('test-note', 'title', 731 | nil, 2, false, nil, nil) 732 | 733 | assert_match(%r%^Notification-Priority: -2%, 734 | @gntp.packet_notify('test-note', 'title', nil, 735 | -2, false, nil, nil)) 736 | assert_match(%r%^Notification-Priority: -1%, 737 | @gntp.packet_notify('test-note', 'title', nil, 738 | -1, false, nil, nil)) 739 | refute_match(%r%^Notification-Priority: 0%, 740 | @gntp.packet_notify('test-note', 'title', nil, 741 | 0, false, nil, nil)) 742 | assert_match(%r%^Notification-Priority: 1%, 743 | @gntp.packet_notify('test-note', 'title', nil, 744 | 1, false, nil, nil)) 745 | assert_match(%r%^Notification-Priority: 2%, 746 | @gntp.packet_notify('test-note', 'title', nil, 747 | 2, false, nil, nil)) 748 | 749 | e = assert_raises ArgumentError do 750 | @gntp.packet_notify 'test-note', 'title', nil, -3, false, nil, nil 751 | end 752 | 753 | assert_equal 'invalid priority level -3', e.message 754 | 755 | e = assert_raises ArgumentError do 756 | @gntp.packet_notify 'test-note', 'title', nil, 3, false, nil, nil 757 | end 758 | 759 | assert_equal 'invalid priority level 3', e.message 760 | end 761 | 762 | def test_packet_notify_sticky 763 | expected = <<-EXPECTED 764 | GNTP/1.0 NOTIFY NONE\r 765 | Application-Name: test-app\r 766 | Origin-Software-Name: ruby-growl\r 767 | Origin-Software-Version: #{Growl::VERSION}\r 768 | Origin-Platform-Name: ruby\r 769 | Origin-Platform-Version: #{RUBY_VERSION}\r 770 | Connection: close\r 771 | Notification-ID: 4\r 772 | Notification-Name: test-note\r 773 | Notification-Title: title\r 774 | Notification-Sticky: True\r 775 | \r 776 | \r 777 | EXPECTED 778 | 779 | assert_equal expected, @gntp.packet_notify('test-note', 'title', 780 | nil, 0, true, nil, nil) 781 | 782 | refute_match(%r%^Notification-Sticky:%, 783 | @gntp.packet_notify('test-note', 'title', nil, 0, false, 784 | nil, nil)) 785 | end 786 | 787 | def test_packet_register 788 | @gntp.add_notification 'test-note' 789 | 790 | expected = <<-EXPECTED 791 | GNTP/1.0 REGISTER NONE\r 792 | Application-Name: test-app\r 793 | Origin-Software-Name: ruby-growl\r 794 | Origin-Software-Version: #{Growl::VERSION}\r 795 | Origin-Platform-Name: ruby\r 796 | Origin-Platform-Version: #{RUBY_VERSION}\r 797 | Connection: close\r 798 | Notifications-Count: 1\r 799 | \r 800 | Notification-Name: test-note\r 801 | Notification-Enabled: true\r 802 | \r 803 | \r 804 | EXPECTED 805 | 806 | assert_equal expected, @gntp.packet_register 807 | end 808 | 809 | def test_packet_register_application_icon 810 | @gntp.add_notification 'test-note' 811 | @gntp.icon = @jpg_url 812 | 813 | expected = <<-EXPECTED 814 | GNTP/1.0 REGISTER NONE\r 815 | Application-Name: test-app\r 816 | Origin-Software-Name: ruby-growl\r 817 | Origin-Software-Version: #{Growl::VERSION}\r 818 | Origin-Platform-Name: ruby\r 819 | Origin-Platform-Version: #{RUBY_VERSION}\r 820 | Connection: close\r 821 | Application-Icon: x-growl-resource://4\r 822 | Notifications-Count: 1\r 823 | \r 824 | Notification-Name: test-note\r 825 | Notification-Enabled: true\r 826 | \r 827 | Identifier: 4\r 828 | Length: #{@jpg_url.size}\r 829 | \r 830 | #{@jpg_url}\r 831 | \r 832 | \r 833 | EXPECTED 834 | 835 | assert_equal expected, @gntp.packet_register 836 | end 837 | 838 | def test_packet_register_application_icon_uri 839 | @gntp.add_notification 'test-note' 840 | @gntp.icon = URI 'http://example/icon.png' 841 | 842 | expected = <<-EXPECTED 843 | GNTP/1.0 REGISTER NONE\r 844 | Application-Name: test-app\r 845 | Origin-Software-Name: ruby-growl\r 846 | Origin-Software-Version: #{Growl::VERSION}\r 847 | Origin-Platform-Name: ruby\r 848 | Origin-Platform-Version: #{RUBY_VERSION}\r 849 | Connection: close\r 850 | Application-Icon: http://example/icon.png\r 851 | Notifications-Count: 1\r 852 | \r 853 | Notification-Name: test-note\r 854 | Notification-Enabled: true\r 855 | \r 856 | \r 857 | EXPECTED 858 | 859 | assert_equal expected, @gntp.packet_register 860 | end 861 | 862 | def test_packet_register_disabled 863 | @gntp.add_notification 'test-note', nil, nil, false 864 | 865 | expected = <<-EXPECTED 866 | GNTP/1.0 REGISTER NONE\r 867 | Application-Name: test-app\r 868 | Origin-Software-Name: ruby-growl\r 869 | Origin-Software-Version: #{Growl::VERSION}\r 870 | Origin-Platform-Name: ruby\r 871 | Origin-Platform-Version: #{RUBY_VERSION}\r 872 | Connection: close\r 873 | Notifications-Count: 1\r 874 | \r 875 | Notification-Name: test-note\r 876 | \r 877 | \r 878 | EXPECTED 879 | 880 | assert_equal expected, @gntp.packet_register 881 | end 882 | 883 | def test_packet_register_display_name 884 | @gntp.add_notification 'test-note', 'Test Note' 885 | 886 | expected = <<-EXPECTED 887 | GNTP/1.0 REGISTER NONE\r 888 | Application-Name: test-app\r 889 | Origin-Software-Name: ruby-growl\r 890 | Origin-Software-Version: #{Growl::VERSION}\r 891 | Origin-Platform-Name: ruby\r 892 | Origin-Platform-Version: #{RUBY_VERSION}\r 893 | Connection: close\r 894 | Notifications-Count: 1\r 895 | \r 896 | Notification-Name: test-note\r 897 | Notification-Display-Name: Test Note\r 898 | Notification-Enabled: true\r 899 | \r 900 | \r 901 | EXPECTED 902 | 903 | assert_equal expected, @gntp.packet_register 904 | end 905 | 906 | def test_packet_register_notification_icon 907 | @gntp.add_notification 'test-note', nil, @jpg_url 908 | 909 | expected = <<-EXPECTED 910 | GNTP/1.0 REGISTER NONE\r 911 | Application-Name: test-app\r 912 | Origin-Software-Name: ruby-growl\r 913 | Origin-Software-Version: #{Growl::VERSION}\r 914 | Origin-Platform-Name: ruby\r 915 | Origin-Platform-Version: #{RUBY_VERSION}\r 916 | Connection: close\r 917 | Notifications-Count: 1\r 918 | \r 919 | Notification-Name: test-note\r 920 | Notification-Enabled: true\r 921 | Notification-Icon: x-growl-resource://4\r 922 | \r 923 | Identifier: 4\r 924 | Length: #{@jpg_url.size}\r 925 | \r 926 | #{@jpg_url}\r 927 | \r 928 | \r 929 | EXPECTED 930 | 931 | assert_equal expected, @gntp.packet_register 932 | end 933 | 934 | def test_packet_register_notification_icon_uri 935 | uri = URI 'http://example/icon.png' 936 | @gntp.add_notification 'test-note', nil, uri 937 | 938 | expected = <<-EXPECTED 939 | GNTP/1.0 REGISTER NONE\r 940 | Application-Name: test-app\r 941 | Origin-Software-Name: ruby-growl\r 942 | Origin-Software-Version: #{Growl::VERSION}\r 943 | Origin-Platform-Name: ruby\r 944 | Origin-Platform-Version: #{RUBY_VERSION}\r 945 | Connection: close\r 946 | Notifications-Count: 1\r 947 | \r 948 | Notification-Name: test-note\r 949 | Notification-Enabled: true\r 950 | Notification-Icon: http://example/icon.png\r 951 | \r 952 | \r 953 | EXPECTED 954 | 955 | assert_equal expected, @gntp.packet_register 956 | end 957 | 958 | def test_parse_header_boolean 959 | assert_equal ['Notification-Enabled', true], 960 | @gntp.parse_header('Notification-Enabled', 'True') 961 | assert_equal ['Notification-Enabled', true], 962 | @gntp.parse_header('Notification-Enabled', 'Yes') 963 | assert_equal ['Notification-Sticky', false], 964 | @gntp.parse_header('Notification-Sticky', 'False') 965 | assert_equal ['Notification-Sticky', false], 966 | @gntp.parse_header('Notification-Sticky', 'No') 967 | end 968 | 969 | def test_parse_header_date 970 | now = Time.at Time.now.to_i 971 | now_8601 = now.iso8601 972 | assert_equal ['Notification-Callback-Timestamp', now], 973 | @gntp.parse_header('Notification-Callback-Timestamp', now_8601) 974 | end 975 | 976 | def test_parse_header_integer 977 | assert_equal ['Error-Code', 200], 978 | @gntp.parse_header('Error-Code', '200') 979 | assert_equal ['Notifications-Count', 2], 980 | @gntp.parse_header('Notifications-Count', '2') 981 | assert_equal ['Notifications-Priority', 2], 982 | @gntp.parse_header('Notifications-Priority', '2') 983 | assert_equal ['Subscriber-Port', 23053], 984 | @gntp.parse_header('Subscriber-Port', '23053') 985 | assert_equal ['Subscription-TTL', 60], 986 | @gntp.parse_header('Subscription-TTL', '60') 987 | end 988 | 989 | def test_parse_header_string 990 | value = 'test' 991 | value.encode! Encoding::BINARY 992 | 993 | header = @gntp.parse_header('Application-Name', value) 994 | assert_equal ['Application-Name', 'test'], header 995 | assert_equal Encoding::UTF_8, header.last.encoding 996 | 997 | header = @gntp.parse_header('Application-Name', '(null)') 998 | assert_equal ['Application-Name', nil], header 999 | 1000 | assert_equal ['Application-Name', 'test'], 1001 | @gntp.parse_header('Application-Name', 'test') 1002 | assert_equal ['Error-Description', 'test'], 1003 | @gntp.parse_header('Error-Description', 'test') 1004 | assert_equal ['Notification-Name', 'test'], 1005 | @gntp.parse_header('Notification-Name', 'test') 1006 | assert_equal ['Notification-Display-Name', 'test'], 1007 | @gntp.parse_header('Notification-Display-Name', 'test') 1008 | assert_equal ['Notification-ID', 'test'], 1009 | @gntp.parse_header('Notification-ID', 'test') 1010 | assert_equal ['Notification-Title', 'test'], 1011 | @gntp.parse_header('Notification-Title', 'test') 1012 | assert_equal ['Notification-Text', 'test'], 1013 | @gntp.parse_header('Notification-Text', 'test') 1014 | assert_equal ['Notification-Coalescing-ID', 'test'], 1015 | @gntp.parse_header('Notification-Coalescing-ID', 'test') 1016 | assert_equal ['Notification-Callback-Context', 'test'], 1017 | @gntp.parse_header('Notification-Callback-Context', 'test') 1018 | assert_equal ['Notification-Callback-Context-Type', 'test'], 1019 | @gntp.parse_header('Notification-Callback-Context-Type', 'test') 1020 | assert_equal ['Notification-Callback-Result', 'test'], 1021 | @gntp.parse_header('Notification-Callback-Result', 'test') 1022 | assert_equal ['Notification-Callback-Target', 'test'], 1023 | @gntp.parse_header('Notification-Callback-Target', 'test') 1024 | assert_equal ['Subscriber-ID', 'test'], 1025 | @gntp.parse_header('Subscriber-ID', 'test') 1026 | assert_equal ['Subscriber-Name', 'test'], 1027 | @gntp.parse_header('Subscriber-Name', 'test') 1028 | assert_equal ['Origin-Machine-Name', 'test'], 1029 | @gntp.parse_header('Origin-Machine-Name', 'test') 1030 | assert_equal ['Origin-Sofware-Name', 'test'], 1031 | @gntp.parse_header('Origin-Sofware-Name', 'test') 1032 | assert_equal ['Origin-Software-Version', 'test'], 1033 | @gntp.parse_header('Origin-Software-Version', 'test') 1034 | assert_equal ['Origin-Platform-Name', 'test'], 1035 | @gntp.parse_header('Origin-Platform-Name', 'test') 1036 | assert_equal ['Origin-Platform-Version', 'test'], 1037 | @gntp.parse_header('Origin-Platform-Version', 'test') 1038 | end 1039 | 1040 | def test_parse_header_url 1041 | http = URI 'http://example/some?page' 1042 | 1043 | assert_equal ['Application-Icon', http], 1044 | @gntp.parse_header('Application-Icon', 1045 | 'http://example/some?page') 1046 | 1047 | res = URI 'x-growl-resource://unique' 1048 | assert_equal ['Notification-Icon', res], 1049 | @gntp.parse_header('Notification-Icon', 1050 | 'x-growl-resource://unique') 1051 | end 1052 | 1053 | def test_receive_callback 1054 | packet = <<-PACKET 1055 | GNTP/1.0 -CALLBACK NONE\r 1056 | Response-Action: NOTIFY\r 1057 | Notification-ID: 4\r 1058 | Notification-Callback-Result: CLICKED\r 1059 | Notification-Callback-Timestamp: 2012-03-28\r 1060 | Notification-Callback-Context: context\r 1061 | Notification-Callback-Context-Type: type\r 1062 | Application-Name: test\r 1063 | PACKET 1064 | 1065 | headers = @gntp.receive packet 1066 | 1067 | expected = { 1068 | 'Response-Action' => 'NOTIFY', 1069 | 'Notification-ID' => '4', 1070 | 'Notification-Callback-Result' => 'CLICKED', 1071 | 'Notification-Callback-Timestamp' => Time.parse('2012-03-28'), 1072 | 'Notification-Callback-Context' => 'context', 1073 | 'Notification-Callback-Context-Type' => 'type', 1074 | 'Application-Name' => 'test' 1075 | } 1076 | 1077 | assert_equal expected, headers 1078 | end 1079 | 1080 | def test_receive_error 1081 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1082 | "Error-Description: (null)\r\nError-Code: 200\r\n\r\n\r\n" 1083 | 1084 | e = assert_raises Growl::GNTP::TimedOut do 1085 | @gntp.receive packet 1086 | end 1087 | 1088 | expected = { 1089 | 'Error-Code' => 200, 1090 | 'Error-Description' => nil, 1091 | 'Response-Action' => nil, 1092 | } 1093 | 1094 | assert_equal expected, e.headers 1095 | 1096 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1097 | "Error-Description: (null)\r\nError-Code: 201\r\n\r\n\r\n" 1098 | 1099 | assert_raises Growl::GNTP::NetworkFailure do 1100 | @gntp.receive packet 1101 | end 1102 | 1103 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1104 | "Error-Description: (null)\r\nError-Code: 300\r\n\r\n\r\n" 1105 | 1106 | assert_raises Growl::GNTP::InvalidRequest do 1107 | @gntp.receive packet 1108 | end 1109 | 1110 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1111 | "Error-Description: (null)\r\nError-Code: 301\r\n\r\n\r\n" 1112 | 1113 | assert_raises Growl::GNTP::UnknownProtocol do 1114 | @gntp.receive packet 1115 | end 1116 | 1117 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1118 | "Error-Description: (null)\r\nError-Code: 302\r\n\r\n\r\n" 1119 | 1120 | assert_raises Growl::GNTP::UnknownProtocolVersion do 1121 | @gntp.receive packet 1122 | end 1123 | 1124 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1125 | "Error-Description: (null)\r\nError-Code: 303\r\n\r\n\r\n" 1126 | 1127 | assert_raises Growl::GNTP::RequiredHeaderMissing do 1128 | @gntp.receive packet 1129 | end 1130 | 1131 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1132 | "Error-Description: (null)\r\nError-Code: 400\r\n\r\n\r\n" 1133 | 1134 | assert_raises Growl::GNTP::NotAuthorized do 1135 | @gntp.receive packet 1136 | end 1137 | 1138 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1139 | "Error-Description: (null)\r\nError-Code: 401\r\n\r\n\r\n" 1140 | 1141 | assert_raises Growl::GNTP::UnknownApplication do 1142 | @gntp.receive packet 1143 | end 1144 | 1145 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1146 | "Error-Description: (null)\r\nError-Code: 402\r\n\r\n\r\n" 1147 | 1148 | assert_raises Growl::GNTP::UnknownNotification do 1149 | @gntp.receive packet 1150 | end 1151 | 1152 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1153 | "Error-Description: (null)\r\nError-Code: 403\r\n\r\n\r\n" 1154 | 1155 | assert_raises Growl::GNTP::AlreadyProcessed do 1156 | @gntp.receive packet 1157 | end 1158 | 1159 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1160 | "Error-Description: (null)\r\nError-Code: 404\r\n\r\n\r\n" 1161 | 1162 | assert_raises Growl::GNTP::NotificationDisabled do 1163 | @gntp.receive packet 1164 | end 1165 | 1166 | packet = "GNTP/1.0 -ERROR NONE\r\nResponse-Action: (null)\r\n" \ 1167 | "Error-Description: (null)\r\nError-Code: 500\r\n\r\n\r\n" 1168 | 1169 | assert_raises Growl::GNTP::InternalServerError do 1170 | @gntp.receive packet 1171 | end 1172 | end 1173 | 1174 | def test_receive_ok 1175 | packet = "\r\nGNTP/1.0 -OK NONE\r\nResponse-Action: REGISTER\r\n\r\n\r\n" 1176 | 1177 | headers = @gntp.receive packet 1178 | 1179 | expected = { 1180 | 'Response-Action' => 'REGISTER' 1181 | } 1182 | 1183 | assert_equal expected, headers 1184 | end 1185 | 1186 | def test_salt 1187 | salt = @gntp.salt 1188 | 1189 | assert_kind_of String, salt 1190 | assert_equal 16, salt.length 1191 | end 1192 | 1193 | def test_send 1194 | stub_socket "GNTP/1.0 -OK NONE\r\nResponse-Action: REGISTER\r\n\r\n\r\n" 1195 | 1196 | result = @gntp.send "hello" 1197 | 1198 | expected = { 1199 | 'Response-Action' => 'REGISTER' 1200 | } 1201 | 1202 | assert_equal expected, result 1203 | 1204 | assert_equal "hello", @gntp._socket._output.string 1205 | 1206 | assert_empty @gntp._socket.read.strip 1207 | end 1208 | 1209 | def assert_endecrypt cipher, key, iv 1210 | encrypted = cipher.update 'this is a test payload' 1211 | encrypted << cipher.final 1212 | 1213 | plain = decrypt cipher, key, iv, encrypted 1214 | 1215 | assert_equal 'this is a test payload', plain 1216 | end 1217 | 1218 | def decrypt cipher, key, iv, encrypted 1219 | decipher = OpenSSL::Cipher.new cipher.name 1220 | decipher.decrypt 1221 | decipher.key = key 1222 | decipher.iv = iv 1223 | 1224 | plain = decipher.update encrypted 1225 | plain << decipher.final 1226 | 1227 | plain 1228 | end 1229 | 1230 | def stub_salt 1231 | def @gntp.salt 1232 | [152, 215, 233, 14, 170, 24, 254, 65].pack 'C*' 1233 | end 1234 | end 1235 | 1236 | def stub_socket response 1237 | @gntp.instance_variable_set :@_response, response 1238 | def @gntp.connect 1239 | @_socket = Socket.new 1240 | @_socket._input = @_response 1241 | @_socket 1242 | end 1243 | 1244 | def @gntp._socket 1245 | @_socket 1246 | end 1247 | end 1248 | 1249 | end 1250 | 1251 | --------------------------------------------------------------------------------