├── init.rb ├── bin ├── ctl_sendserver └── sendserver.rb ├── lib ├── apns4r.rb ├── feedbackreader.rb ├── config.rb ├── apnsconnection.rb ├── sender.rb └── apncore.rb ├── apns4r.sample.yml ├── apns4r_rails.sample.yml ├── MIT-LICENSE └── README.markdown /init.rb: -------------------------------------------------------------------------------- 1 | require 'apns4r' 2 | -------------------------------------------------------------------------------- /bin/ctl_sendserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'rubygems' 3 | require 'daemons' 4 | Daemons.run(File.expand_path(File.dirname(__FILE__))+'/sendserver.rb', {:monitor => true}) 5 | -------------------------------------------------------------------------------- /lib/apns4r.rb: -------------------------------------------------------------------------------- 1 | $: << File.expand_path(File.dirname(__FILE__)) 2 | require 'config' 3 | require 'apncore' 4 | require 'apnsconnection' 5 | require 'sender' 6 | #require 'feedbackreader' 7 | -------------------------------------------------------------------------------- /lib/feedbackreader.rb: -------------------------------------------------------------------------------- 1 | module APNs4r 2 | 3 | class FeedbackReader < ApnsConnection 4 | 5 | attr_accessor :host, :port 6 | def initialize host = OPTIONS[:apns4r_feedback_host], port = OPTIONS[:apns4r_feedback_port] 7 | @host, @port = host, port 8 | end 9 | 10 | def read 11 | @ssl ||= connect 12 | 13 | records ||= [] 14 | while record = @@ssl.read(38) 15 | records << record.unpack('NnH*') 16 | end 17 | @@ssl.close 18 | records 19 | end 20 | 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /lib/config.rb: -------------------------------------------------------------------------------- 1 | OPTIONS = 2 | if defined?(Rails.env) 3 | raw_config = File.read(Rails.root.join("config", "apns4r.yml") 4 | parsed_config = ERB.new(raw_config).result 5 | YAML.load(parsed_config)[Rails.env].symbolize_keys 6 | else 7 | require 'erb' 8 | raw_config = File.read(File.expand_path(File.dirname(__FILE__)) + "/../apns4r.yml") 9 | parsed_config = ERB.new(raw_config).result 10 | YAML.load(parsed_config).inject({}) do |options, (key, value)| 11 | options[(key.to_sym rescue key) || key] = value 12 | options 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /apns4r.sample.yml: -------------------------------------------------------------------------------- 1 | apns4r_cert_file: <%= File.dirname(__FILE__) + '/cert/apns_developer_identity.cer' %> 2 | apns4r_cert_key: <%= File.dirname(__FILE__) + '/cert/apns_developer_private_key.pem' %> 3 | apns4r_push_host: 'gateway.sandbox.push.apple.com' # or 'gateway.push.apple.com' when you ready 4 | apns4r_push_port: 2195 5 | apns4r_feedback_host: 'feedback.sandbox.push.apple.com' # 'feedback.push.apple.com' 6 | apns4r_feedback_port: 2196 7 | 8 | apns4r_ping_device_token: <%= "0000000000000000000000000000000000000000000000000000000000000000".hex %> 9 | apns4r_sendserver_host: 'localhost' 10 | apns4r_sendserver_port: 8801 11 | -------------------------------------------------------------------------------- /apns4r_rails.sample.yml: -------------------------------------------------------------------------------- 1 | # /config/application.yml 2 | defaults: &defaults 3 | apns4r_cert_file: <%= Rails.root.join("cert", "apns_#{Rails.env}_identity.cer") %> 4 | apns4r_cert_key: <%= Rails.root.join("cert", "apns_#{Rails.env}_private_key.pem") %> 5 | apns4r_push_host: 'gateway.sandbox.push.apple.com' 6 | apns4r_push_port: 2195 7 | apns4r_feedback_host: 'feedback.sandbox.push.apple.com' 8 | apns4r_feedback_port: 2196 9 | 10 | development: 11 | <<: *defaults 12 | profile: true 13 | 14 | test: 15 | <<: *defaults 16 | 17 | production: 18 | <<: *defaults 19 | apns4r_push_host: 'gateway.push.apple.com' 20 | apns4r_feedback_host: 'feedback.push.apple.com' 21 | -------------------------------------------------------------------------------- /lib/apnsconnection.rb: -------------------------------------------------------------------------------- 1 | module APNs4r 2 | 3 | require 'socket' 4 | require 'openssl' 5 | 6 | class ApnsConnection 7 | 8 | protected 9 | def connect host, port, overrides = {} 10 | ctx = OpenSSL::SSL::SSLContext.new() 11 | ctx.cert = OpenSSL::X509::Certificate.new(File::read(overrides[:apns4r_cert_file] || OPTIONS[:apns4r_cert_file])) 12 | ctx.key = OpenSSL::PKey::RSA.new(File::read(overrides[:apns4r_cert_key] || OPTIONS[:apns4r_cert_key])) 13 | 14 | begin 15 | s = TCPSocket.new(host, port) 16 | ssl = OpenSSL::SSL::SSLSocket.new(s, ctx) 17 | ssl.connect # start SSL session 18 | ssl.sync_close = true # close underlying socket on SSLSocket#close 19 | ssl 20 | rescue Errno::ETIMEDOUT 21 | nil 22 | end 23 | end 24 | 25 | end 26 | 27 | end 28 | 29 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Leonid Ponomarev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /bin/sendserver.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | #add script's dir to require path 4 | $: << File.expand_path(File.dirname(__FILE__))+'/../' 5 | ['eventmachine', 'logger', 'lib/apns4r'].each{|lib| require lib} 6 | 7 | $logger = Logger.new("#{File.expand_path(File.dirname(__FILE__))}/../log/sendserver.log", 10, 1024000) 8 | # properly close all connections and sokets 9 | def stop 10 | APNs4r::Sender.close_connection 11 | EventMachine::stop_server $server 12 | $logger.info "SendServer stopped" 13 | exit 14 | end 15 | Signal.trap("TERM") {stop} 16 | Signal.trap("INT") {stop} 17 | 18 | 19 | module SendServer 20 | def post_init 21 | $logger.info "Incoming connection" 22 | @sender = APNs4r::Sender.new 23 | end 24 | 25 | def receive_data data 26 | # TODO store notifications for later batch transmission 27 | # only when some scaling needed 28 | data = data.chomp 29 | @sender.push data 30 | $logger.info Notification.parse(data).payload 31 | end 32 | 33 | def unbind 34 | $logger.info "Connection closed" 35 | end 36 | end 37 | 38 | EventMachine::run { 39 | if APNs4r::Sender.establish_connection :sandbox 40 | # pinging our device to avoid socket close by APNs 41 | EventMachine::add_periodic_timer( 300 ) do 42 | payload = { :ping => Time.now.to_i.to_s } 43 | notification = APNs4r::Notification.create OPTIONS[:apns4r_ping_device_token], payload 44 | APNs4r::Sender.push notification 45 | end 46 | $logger.info "SendServer started" 47 | $server = EventMachine::start_server OPTIONS[:apns4r_sendserver_host], OPTIONS[:apns4r_sendserver_port], SendServer 48 | else 49 | $logger.error "SendServer: failed to connect to APNs: timeout" 50 | exit 1 51 | end 52 | } 53 | -------------------------------------------------------------------------------- /lib/sender.rb: -------------------------------------------------------------------------------- 1 | module APNs4r 2 | 3 | class Sender < ApnsConnection 4 | 5 | attr_accessor :host, :port 6 | 7 | # Creates new {Sender} object with given host and port 8 | # 9 | # Accepts params in 2 ways, either as 2 strings : 10 | # @param [String] host default to APNs sandbox 11 | # @param [Fixnum] port don't think it can change, just in case 12 | # 13 | # or as a Hash of optional arguments: 14 | # :host => [String] host default to APNs sandbox 15 | # :port => [Fixnum] port don't think it can change, just in case 16 | # :apns4r_cert_file => [String] path to cert file (used to support multiple iphone applications from one server) 17 | # :apns4r_cert_key => [String] path to cert key (as above) 18 | def initialize *args 19 | 20 | if args[0].is_a? Hash 21 | options = args[0] 22 | @host = options.delete(:host) || OPTIONS[:apns4r_push_host] 23 | @port = options.delete(:port) || OPTIONS[:apns4r_push_port] 24 | 25 | @ssl ||= connect(@host, @port, options) 26 | else 27 | @host = args[0] || OPTIONS[:apns4r_push_host] 28 | @port = args[1] || OPTIONS[:apns4r_push_port] 29 | 30 | @ssl ||= connect(@host, @port) 31 | end 32 | self 33 | end 34 | 35 | # sends {Notification} object to Apple's server 36 | # @param [Notification] notification notification to send 37 | # @example 38 | # n = APNs4r::Notification.create 'e754dXXXX...', { :aps => {:alert => "Hey, dude!", :badge => 1}, :custom_data => "asd" } 39 | # sender = APNs4r::Sender.new.push n 40 | def push notification 41 | delay = 2 42 | begin 43 | @ssl.write notification.to_s 44 | rescue OpenSSL::SSL::SSLError, Errno::EPIPE 45 | sleep delay 46 | @ssl = connect(@host, @port) 47 | delay*=2 and retry if delay < 60 48 | raise Timeout::Error 49 | end 50 | end 51 | 52 | def close_connection 53 | @ssl.close 54 | @ssl = nil 55 | end 56 | 57 | end 58 | 59 | end 60 | 61 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Apns4r 2 | ====== 3 | 4 | This lib is intended to allow write my own APNs provider for Apple Push 5 | Notificaion services (APNs) in ruby. Requires json gem. 6 | 7 | Can be used as Rails plugin too. No models and rake tasks, just pick sample 8 | config and fire-and-forget this notifications. 9 | 10 | Installation 11 | ============ 12 | Use git, Luke! 13 | For Rails: 14 | 15 | ./script/plugin install git://github.com/thegeekbird/Apns4r.git 16 | 17 | and for the rest world `git submodule` can be a solution: 18 | 19 | git submodule add git://github.com/thegeekbird/Apns4r.git vendor/Apns4r 20 | 21 | Configuration 22 | ============= 23 | 24 | If you want to use APNs sertificates with ruby (ie with OpenSSL), 25 | here's a tip from [great post about integration between Python and APNs](http://blog.nuclearbunny.org/2009/05/11/connecting-to-apple-push-notification-services-using-python-twisted/): 26 | 27 | >One caveat - the Mac OS X Keychain Access application does not directly export 28 | >certificates and private keys in Private Enhanced Mail (.pem) format, which is 29 | >what the OpenSSL implementation we use with Twisted will want, but luckily 30 | >there’s an easy mechanism to convert if you export the files as Personal 31 | >Information Exchange (.p12) format. The following two commands can be used to 32 | >convert the .p12 files into .pem files using the built-in openssl command on 33 | >Mac OS X or most Linux distributions: 34 | openssl pkcs12 -in cred.p12 -out certkey.pem -nodes -clcerts 35 | openssl pcks12 -in pkey.p12 -out pkey.pem -nodes -clcerts 36 | 37 | For code samples see Example section, also there is simple EventMachine powered 38 | sendserver.rb and two config samples. 39 | 40 | Example 41 | ======= 42 | 43 | All simple as require - create Notification - push, just try it in irb 44 | 45 | require 'lib/apns4r' #=> true 46 | n = APNs4r::Notification.create 'e754dXXX', { :aps => {:alert => "Hey, dude!", :badge => 1}, :custom_data => "asd" } #=> # 47 | APNs4r::Sender.new.push n #=> 97 48 | 49 | Doc 50 | === 51 | [Gimme moar guts](http://rdoc.info/projects/thegeekbird/Apns4r) 52 | 53 | Copyright (c) 2009 Leonid Ponomarev, released under the MIT license 54 | -------------------------------------------------------------------------------- /lib/apncore.rb: -------------------------------------------------------------------------------- 1 | $KCODE='u' and require 'jcode' if RUBY_VERSION =~ /1.8/ 2 | require 'json' 3 | 4 | class Hash 5 | MAX_PAYLOAD_LEN = 256 6 | 7 | # Converts hash into JSON String. 8 | # When payload is too long but can be chopped, tries to cut self.[:aps][:alert]. 9 | # If payload still don't fit Apple's restrictions, returns nil 10 | # 11 | # @return [String, nil] the object converted into JSON or nil. 12 | def to_payload 13 | # Payload too long 14 | if (to_json.length > MAX_PAYLOAD_LEN) 15 | alert = self[:aps][:alert] 16 | self[:aps][:alert] = '' 17 | # can be chopped? 18 | if (to_json.length > MAX_PAYLOAD_LEN) 19 | return nil 20 | else # inefficient way, but payload may be full of unicode-escaped chars, so... 21 | self[:aps][:alert] = alert 22 | while (self.to_json.length > MAX_PAYLOAD_LEN) 23 | self[:aps][:alert].chop! 24 | end 25 | end 26 | end 27 | to_json 28 | end 29 | 30 | # Invokes {Hash#to_payload} and returns it's length 31 | # @return [Fixnum, nil] length of object converted into JSON or nil. 32 | def payload_length 33 | p = to_payload 34 | p ? p.length : nil 35 | end 36 | 37 | end 38 | 39 | module APNs4r 40 | 41 | class Notification 42 | 43 | def initialize token, payload 44 | @token, @payload = token, payload 45 | end 46 | 47 | # Creates new notification with given token and payload 48 | # @param [String, Fixnum] token APNs token of device to notify 49 | # @param [Hash, String] payload attached payload 50 | # @example 51 | # APNs4r::Notification.create 'e754dXXXX...', { :aps => {:alert => "Hey, dude!", :badge => 1}, :custom_data => "asd" } 52 | def Notification.create(token, payload) 53 | Notification.new token.kind_of?(String) ? token.delete(' ') : token.to_s(16) , payload.kind_of?(Hash) ? payload.to_payload : payload 54 | end 55 | 56 | # Converts to binary string wich can be writen directly into socket 57 | # @return [String] binary string representation 58 | def to_s 59 | [0, 32, @token, @payload.length, @payload ].pack("CnH*na*") 60 | end 61 | 62 | # Counterpart of {Notification#to_s} - parses from binary string 63 | # @param [String] bitstring string to parse 64 | # @return [Notification] parsed Notification object 65 | def Notification.parse bitstring 66 | command, tokenlen, token, payloadlen, payload = bitstring.unpack("CnH64na*") 67 | Notification.new(token, payload) 68 | end 69 | 70 | end 71 | 72 | class FeedbackServiceResponce 73 | 74 | attr_accessor :timestamp, :token 75 | 76 | def initialize bitstring 77 | @timestamp, tokenlen, @token = *bitstring.unpack('NnH*') 78 | end 79 | 80 | end 81 | 82 | end 83 | --------------------------------------------------------------------------------