apn_sender 0.0.3 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc CHANGED
@@ -9,7 +9,7 @@ The apn_sender gem includes a background daemon which processes background messa
9
9
 
10
10
  == Usage
11
11
 
12
- === Queueing Messages From Your Application
12
+ === 1. Queueing Messages From Your Application
13
13
 
14
14
  To queue a message for sending through Apple's Push Notification service from your Rails application:
15
15
 
@@ -22,7 +22,7 @@ where +token+ is the unique identifier of the iPhone to receive the notification
22
22
  # :sound #=> The sound file to play on receipt, or true to play the default sound installed with your app
23
23
  # :custom #=> Hash of application-specific custom data to send along with the notification
24
24
 
25
- === Getting Messages Actually Sent to Apple
25
+ === 2. Sending Queued Messages
26
26
 
27
27
  Put your <code>apn_development.pem</code> and <code>apn_production.pem</code> certificates from Apple in your <code>RAILS_ROOT/config/certs</code> directory.
28
28
 
@@ -40,6 +40,43 @@ For production, you're probably better off running a dedicated daemon and settin
40
40
 
41
41
  Note the --environment must be explicitly set (separately from your <code>RAILS_ENV</code>) to production in order to send messages via the production APN servers. Any other environment sends messages through Apple's sandbox servers at <code>gateway.sandbox.push.apple.com</code>.
42
42
 
43
+ === 3. Checking Apple's Feedback Service
44
+
45
+ Since push notifications are a fire-and-forget sorta deal, where you get no indication if your message was received (or if the specified recipient even exists), Apple needed to come up with some other way to ensure their network isn't clogged with thousands of bogus messages (e.g. from developers sending messages to phones where their application <em>used</em> to be installed, but where the user has since removed it). Hence, the Feedback Service.
46
+
47
+ It's actually really simple - you connect to them periodically and they give you a big dump of tokens you shouldn't send to anymore. The gem wraps this up nicely -- just call:
48
+
49
+ # APN::Feedback accepts the same optional :environment and :cert_path options as APN::Sender
50
+ feedback = APN::Feedback.new()
51
+
52
+ tokens = feedback.tokens # => Array of device tokens
53
+ tokens.each do |token|
54
+ # ... custom logic here to stop you app from
55
+ # sending further notifications to this token
56
+ end
57
+
58
+ If you're interested in knowing exactly <em>when</em> Apple determined each token was expired (which can be useful in determining if the application re-registered with your service since it first appeared in the expired queue):
59
+
60
+ items = feedback.data # => Array of APN::FeedbackItem elements
61
+ items.each do |item|
62
+ item.token
63
+ item.timestamp
64
+ # ... custom logic here
65
+ end
66
+
67
+ The Feedback Service works as a big queue, and when you connect it pops off all its data and sends it over the wire. This means that connecting a second time will return an empty array. For ease of use, a call to either +tokens+ or +data+ will connect once and cache the data, so if you call either one again it'll continue to use its cached version rather than connecting to Apple a second time to retrieve an empty array.
68
+
69
+ Forcing a reconnect is as easy as calling either method with the single parameter +true+, but be sure you've already used the existing data because you'll never get it back.
70
+
71
+
72
+ ==== Warning: No really, check Apple's Feedback Service occasionally
73
+
74
+ If you're sending notifications, you should definitely call one of the <code>receive</code> methods periodically, as Apple's policies require it and they apparently monitors providers for compliance. I'd definitely recommend throwing together a quick rake task to take care of this for you (the {whenever library}[http://github.com/javan/whenever] provides a nice wrapper around scheduling tasks to run at certain times (for systems with cron enabled)).
75
+
76
+
77
+
78
+
79
+
43
80
  === Keeping Your Workers Working
44
81
 
45
82
  There's also an included sample <code>apn_sender.monitrc</code> file in the <code>contrib/</code> folder to help monit handle server restarts and unexpected disasters.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.3
1
+ 0.0.4
data/apn_sender.gemspec CHANGED
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{apn_sender}
8
- s.version = "0.0.3"
8
+ s.version = "0.0.4"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Kali Donovan"]
12
- s.date = %q{2010-04-25}
12
+ s.date = %q{2010-05-02}
13
13
  s.description = %q{Resque-based background worker to send Apple Push Notifications over a persistent TCP socket. Includes Resque tweaks to allow persistent sockets between jobs, helper methods for enqueueing APN notifications, and a background daemon to send them.}
14
14
  s.email = %q{kali.donovan@gmail.com}
15
15
  s.extra_rdoc_files = [
@@ -29,6 +29,8 @@ Gem::Specification.new do |s|
29
29
  "generators/templates/script",
30
30
  "init.rb",
31
31
  "lib/apn.rb",
32
+ "lib/apn/connection/base.rb",
33
+ "lib/apn/feedback.rb",
32
34
  "lib/apn/notification.rb",
33
35
  "lib/apn/notification_job.rb",
34
36
  "lib/apn/queue_manager.rb",
@@ -0,0 +1,97 @@
1
+ require 'socket'
2
+ require 'openssl'
3
+ require 'resque'
4
+
5
+ module APN
6
+ module Connection
7
+ module Base
8
+ attr_accessor :opts, :logger
9
+
10
+ def initialize(opts = {})
11
+ @opts = opts
12
+
13
+ setup_logger
14
+ apn_log(:info, "APN::Sender initializing. Establishing connections first...") if @opts[:verbose]
15
+ setup_paths
16
+
17
+ super( APN::QUEUE_NAME ) if self.class.ancestors.include?(Resque::Worker)
18
+ end
19
+
20
+ # Lazy-connect the socket once we try to access it in some way
21
+ def socket
22
+ setup_connection unless @socket
23
+ return @socket
24
+ end
25
+
26
+ protected
27
+
28
+ def setup_logger
29
+ @logger = if defined?(Merb::Logger)
30
+ Merb.logger
31
+ elsif defined?(RAILS_DEFAULT_LOGGER)
32
+ RAILS_DEFAULT_LOGGER
33
+ end
34
+ end
35
+
36
+ def apn_log(level, message)
37
+ return false unless self.logger
38
+ self.logger.send(level, "#{Time.now}: #{message}")
39
+ end
40
+
41
+ def apn_production?
42
+ @opts[:environment] && @opts[:environment] != '' && :production == @opts[:environment].to_sym
43
+ end
44
+
45
+ # Get a fix on the .pem certificate we'll be using for SSL
46
+ def setup_paths
47
+ # Set option defaults
48
+ @opts[:cert_path] ||= File.join(File.expand_path(RAILS_ROOT), "config", "certs") if defined?(RAILS_ROOT)
49
+ @opts[:environment] ||= RAILS_ENV if defined?(RAILS_ENV)
50
+
51
+ raise "Missing certificate path. Please specify :cert_path when initializing class." unless @opts[:cert_path]
52
+ cert_name = apn_production? ? "apn_production.pem" : "apn_development.pem"
53
+ cert_path = File.join(@opts[:cert_path], cert_name)
54
+
55
+ @apn_cert = File.exists?(cert_path) ? File.read(cert_path) : nil
56
+ raise "Missing apple push notification certificate in #{cert_path}" unless @apn_cert
57
+ end
58
+
59
+ # Open socket to Apple's servers
60
+ def setup_connection
61
+ raise "Missing apple push notification certificate" unless @apn_cert
62
+ return true if @socket && @socket_tcp
63
+ raise "Trying to open half-open connection" if @socket || @socket_tcp
64
+
65
+ ctx = OpenSSL::SSL::SSLContext.new
66
+ ctx.cert = OpenSSL::X509::Certificate.new(@apn_cert)
67
+ ctx.key = OpenSSL::PKey::RSA.new(@apn_cert)
68
+
69
+ @socket_tcp = TCPSocket.new(apn_host, apn_port)
70
+ @socket = OpenSSL::SSL::SSLSocket.new(@socket_tcp, ctx)
71
+ @socket.sync = true
72
+ @socket.connect
73
+ rescue SocketError => error
74
+ apn_log(:error, "Error with connection to #{apn_host}: #{error}")
75
+ raise "Error with connection to #{apn_host}: #{error}"
76
+ end
77
+
78
+ # Close open sockets
79
+ def teardown_connection
80
+ apn_log(:info, "Closing connections...") if @opts[:verbose]
81
+
82
+ begin
83
+ @socket.close if @socket
84
+ rescue Exception => e
85
+ apn_log(:error, "Error closing SSL Socket: #{e}")
86
+ end
87
+
88
+ begin
89
+ @socket_tcp.close if @socket_tcp
90
+ rescue Exception => e
91
+ apn_log(:error, "Error closing TCP Socket: #{e}")
92
+ end
93
+ end
94
+
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,92 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/connection/base')
2
+
3
+ module APN
4
+ # Encapsulates data returned from the {APN Feedback Service}[http://developer.apple.com/iphone/library/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW3].
5
+ # Possesses +timestamp+ and +token+ attributes.
6
+ class FeedbackItem
7
+ attr_accessor :timestamp, :token
8
+
9
+ def initialize(time, token)
10
+ @timestamp = time
11
+ @token = token
12
+ end
13
+
14
+ # For convenience, return the token on to_s
15
+ def to_s
16
+ token
17
+ end
18
+ end
19
+
20
+ # When supplied with the certificate path and the desired environment, connects to the {APN Feedback Service}[http://developer.apple.com/iphone/library/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW3]
21
+ # and returns any response as an array of APN::FeedbackItem elements.
22
+ #
23
+ # See README for usage and details.
24
+ class Feedback
25
+ include APN::Connection::Base
26
+
27
+ # Returns array of APN::FeedbackItem elements read from Apple. Connects to Apple once and caches the
28
+ # data, continues to returns cached data unless called with <code>data(true)</code>, which clears the
29
+ # existing feedback array. Note that once you force resetting the cache you loose all previous feedback,
30
+ # so be sure you've already processed it.
31
+ def data(force = nil)
32
+ @feedback = nil if force
33
+ @feedback ||= receive
34
+ end
35
+
36
+ # Wrapper around +data+ returning just an array of token strings.
37
+ def tokens(force = nil)
38
+ data(force).map(&:token)
39
+ end
40
+
41
+ # Prettify to return meaningful status information when printed. Can't add these directly to connection/base, because Resque depends on decoding to_s
42
+ def inspect
43
+ "#<#{self.class.name}: #{to_s}>"
44
+ end
45
+
46
+ # Prettify to return meaningful status information when printed. Can't add these directly to connection/base, because Resque depends on decoding to_s
47
+ def to_s
48
+ "#{@socket ? 'Connected' : 'Connection not currently established'} to #{apn_host} on #{apn_port}"
49
+ end
50
+
51
+ protected
52
+
53
+ # Connects to Apple's Feedback Service and checks if there's anything there for us.
54
+ # Returns an array of APN::FeedbackItem pairs
55
+ def receive
56
+ feedback = []
57
+
58
+ # Hi Apple
59
+ setup_connection
60
+
61
+ # Unpacking code borrowed from http://github.com/jpoz/APNS/blob/master/lib/apns/core.rb
62
+ while line = socket.gets # Read lines from the socket
63
+ line.strip!
64
+ f = line.unpack('N1n1H140')
65
+ feedback << APN::FeedbackItem.new(Time.at(f[0]), f[2])
66
+ end
67
+
68
+ # Bye Apple
69
+ teardown_connection
70
+
71
+ return feedback
72
+ end
73
+
74
+
75
+ def apn_host
76
+ @apn_host ||= apn_production? ? "feedback.push.apple.com" : "feedback.sandbox.push.apple.com"
77
+ end
78
+
79
+ def apn_port
80
+ 2196
81
+ end
82
+
83
+ end
84
+ end
85
+
86
+
87
+
88
+ __END__
89
+ # Testing from irb
90
+ irb -r 'lib/apn/feedback'
91
+
92
+ a=APN::Feedback.new(:cert_path => '/Users/kali/Code/insurrection/certs/', :environment => :production)
@@ -18,28 +18,40 @@ module APN
18
18
  # APN::Notification.new(token, {:alert => 'Some Alert'})
19
19
  #
20
20
  class Notification
21
- attr_accessor :message, :options, :json
21
+ attr_accessor :options, :token
22
22
  def initialize(token, opts)
23
23
  @options = hash_as_symbols(opts.is_a?(Hash) ? opts : {:alert => opts})
24
- @json = generate_apple_json
25
- hex_token = [token.delete(' ')].pack('H*')
26
- @message = "\0\0 #{hex_token}\0#{json.length.chr}#{json}"
27
- raise "The maximum size allowed for a notification payload is 256 bytes." if @message.size.to_i > 256
24
+ @token = token
25
+
26
+ raise "The maximum size allowed for a notification payload is 256 bytes." if packaged_notification.size.to_i > 256
28
27
  end
29
28
 
30
29
  def to_s
31
- @message
30
+ packaged_notification
32
31
  end
33
32
 
33
+ # Ensures at least one of <code>%w(alert badge sound)</code> is present
34
34
  def valid?
35
35
  return true if %w(alert badge sound).any?{|key| options.keys.include?(key.to_sym) }
36
36
  false
37
37
  end
38
38
 
39
39
  protected
40
-
40
+
41
+ # Completed encoded notification, ready to send down the wire to Apple
42
+ def packaged_notification
43
+ pt = packaged_token
44
+ pm = packaged_message
45
+ [0, 0, 32, pt, 0, pm.size, pm].pack("ccca*cca*")
46
+ end
47
+
48
+ # Device token, compressed and hex-ified
49
+ def packaged_token
50
+ [@token.gsub(/[\s|<|>]/,'')].pack('H*')
51
+ end
52
+
41
53
  # Convert the supplied options into the JSON needed for Apple's push notification servers
42
- def generate_apple_json
54
+ def packaged_message
43
55
  hsh = {'aps' => {}}
44
56
  hsh['aps']['alert'] = @options[:alert].to_s if @options[:alert]
45
57
  hsh['aps']['badge'] = @options[:badge].to_i if @options[:badge]
@@ -48,7 +60,7 @@ module APN
48
60
  hsh.merge!(@options[:custom]) if @options[:custom]
49
61
  hsh.to_json
50
62
  end
51
-
63
+
52
64
  # Symbolize keys, using ActiveSupport if available
53
65
  def hash_as_symbols(hash)
54
66
  if hash.respond_to?(:symbolize_keys)
@@ -5,18 +5,19 @@ module APN
5
5
  class NotificationJob
6
6
  # Behind the scenes, this is the name of our Resque queue
7
7
  @queue = APN::QUEUE_NAME
8
-
8
+
9
9
  # Build a notification from arguments and send to Apple
10
10
  def self.perform(token, opts)
11
11
  msg = APN::Notification.new(token, opts)
12
12
  raise "Invalid notification options: #{opts.inspect}" unless msg.valid?
13
+
13
14
  worker.send_to_apple( msg )
14
15
  end
15
-
16
-
16
+
17
+
17
18
  # Only execute this job in specialized APN::Sender workers, since
18
19
  # standard Resque workers don't maintain the persistent TCP connection.
19
20
  extend Resque::Plugins::AccessWorkerFromJob
20
21
  self.required_worker_class = 'APN::Sender'
21
22
  end
22
- end
23
+ end
data/lib/apn/sender.rb CHANGED
@@ -1,7 +1,3 @@
1
- require 'socket'
2
- require 'openssl'
3
- require 'resque'
4
-
5
1
  module APN
6
2
  # Subclass of Resque::Worker which initializes a single TCP socket on creation to communicate with Apple's Push Notification servers.
7
3
  # Shares this socket with each child process forked off by Resque to complete a job. Socket is closed in the before_unregister_worker
@@ -14,94 +10,38 @@ module APN
14
10
  # Rails context, will default to RAILS_ENV and RAILS_ROOT/config/certs. :environment will default to development.
15
11
  # APN::Sender expects two files to exist in the specified <code>:cert_path</code> directory:
16
12
  # <code>apn_production.pem</code> and <code>apn_development.pem</code>.
13
+ #
14
+ # If a socket error is encountered, will teardown the connection and retry again twice before admitting defeat.
17
15
  class Sender < ::Resque::Worker
18
- APN_PORT = 2195
19
- attr_accessor :apn_cert, :apn_host, :socket, :socket_tcp, :opts
20
-
21
- class << self
22
- attr_accessor :logger
23
- end
24
-
25
- self.logger = if defined?(Merb::Logger)
26
- Merb.logger
27
- elsif defined?(RAILS_DEFAULT_LOGGER)
28
- RAILS_DEFAULT_LOGGER
29
- end
30
-
31
- def initialize(opts = {})
32
- @opts = opts
33
-
34
- # Set option defaults
35
- @opts[:cert_path] ||= File.join(File.expand_path(RAILS_ROOT), "config", "certs") if defined?(RAILS_ROOT)
36
- @opts[:environment] ||= RAILS_ENV if defined?(RAILS_ENV)
37
-
38
- logger.info "APN::Sender initializing. Establishing connections first..." if @opts[:verbose]
39
- setup_paths
40
- setup_connection
41
-
42
- super( APN::QUEUE_NAME )
43
- end
44
-
16
+ include APN::Connection::Base
17
+ TIMES_TO_RETRY_SOCKET_ERROR = 2
18
+
45
19
  # Send a raw string over the socket to Apple's servers (presumably already formatted by APN::Notification)
46
- def send_to_apple(msg)
47
- @socket.write( msg.to_s )
20
+ def send_to_apple( notification, attempt = 0 )
21
+ if attempt > TIMES_TO_RETRY_SOCKET_ERROR
22
+ raise "Error with connection to #{apn_host} (retried #{TIMES_TO_RETRY_SOCKET_ERROR} times): #{error}"
23
+ end
24
+
25
+ self.socket.write( notification.to_s )
48
26
  rescue SocketError => error
49
- logger.error("Error with connection to #{@apn_host}: #{error}")
50
- raise "Error with connection to #{@apn_host}: #{error}"
27
+ apn_log(:error, "Error with connection to #{apn_host} (attempt #{attempt}): #{error}")
28
+
29
+ # Try reestablishing the connection
30
+ teardown_connection
31
+ setup_connection
32
+ send_to_apple(notification, attempt + 1)
51
33
  end
52
-
34
+
53
35
  protected
54
36
 
55
- def apn_production?
56
- @opts[:environment] && @opts[:environment] != '' && :production == @opts[:environment].to_sym
37
+ def apn_host
38
+ @apn_host ||= apn_production? ? "gateway.push.apple.com" : "gateway.sandbox.push.apple.com"
57
39
  end
58
40
 
59
- # Get a fix on the .pem certificate we'll be using for SSL
60
- def setup_paths
61
- raise "Missing certificate path. Please specify :cert_path when initializing class." unless @opts[:cert_path]
62
- @apn_host = apn_production? ? "gateway.push.apple.com" : "gateway.sandbox.push.apple.com"
63
- cert_name = apn_production? ? "apn_production.pem" : "apn_development.pem"
64
- cert_path = File.join(@opts[:cert_path], cert_name)
65
-
66
- @apn_cert = File.exists?(cert_path) ? File.read(cert_path) : nil
67
- raise "Missing apple push notification certificate in #{cert_path}" unless @apn_cert
41
+ def apn_port
42
+ 2195
68
43
  end
69
44
 
70
- # Open socket to Apple's servers
71
- def setup_connection
72
- raise "Missing apple push notification certificate" unless @apn_cert
73
- raise "Trying to open already-open socket" if @socket || @socket_tcp
74
-
75
- ctx = OpenSSL::SSL::SSLContext.new
76
- ctx.cert = OpenSSL::X509::Certificate.new(@apn_cert)
77
- ctx.key = OpenSSL::PKey::RSA.new(@apn_cert)
78
-
79
- @socket_tcp = TCPSocket.new(@apn_host, APN_PORT)
80
- @socket = OpenSSL::SSL::SSLSocket.new(@socket_tcp, ctx)
81
- @socket.sync = true
82
- @socket.connect
83
- rescue SocketError => error
84
- logger.error("Error with connection to #{@apn_host}: #{error}")
85
- raise "Error with connection to #{@apn_host}: #{error}"
86
- end
87
-
88
- # Close open sockets
89
- def teardown_connection
90
- logger.info "Closing connections..." if @opts[:verbose]
91
-
92
- begin
93
- @socket.close if @socket
94
- rescue Exception => e
95
- logger.error("Error closing SSL Socket: #{e}")
96
- end
97
-
98
- begin
99
- @socket_tcp.close if @socket_tcp
100
- rescue Exception => e
101
- logger.error("Error closing TCP Socket: #{e}")
102
- end
103
- end
104
-
105
45
  end
106
46
 
107
47
  end
@@ -112,9 +52,14 @@ __END__
112
52
  # irb -r 'lib/apple_push_notification'
113
53
 
114
54
  ## To enqueue test job
115
- Resque.enqueue APN::NotificationJob, 'ceecdc18 ef17b2d0 745475e0 0a6cd5bf 54534184 ac2649eb 40873c81 ae76dbe8', {:alert => 'Resque Test'}
55
+ k = 'ceecdc18 ef17b2d0 745475e0 0a6cd5bf 54534184 ac2649eb 40873c81 ae76dbe8'
56
+ c = '0f58e3e2 77237b8f f8213851 c835dee0 376b7a31 9e0484f7 06fe3035 7c5dda2f'
57
+ Resque.enqueue APN::NotificationJob, k, {:alert => 'Resque Test'}
116
58
  APN.notify 'ceecdc18 ef17b2d0 745475e0 0a6cd5bf 54534184 ac2649eb 40873c81 ae76dbe8', 'Resque Test'
117
59
 
60
+ # If you need to really force quit some screwed up workers
61
+ Resque.workers.map{|w| Resque.redis.srem(:workers, w)}
62
+
118
63
  ## To run worker from rake task
119
64
  CERT_PATH=/Users/kali/Code/insurrection/certs/ ENVIRONMENT=production rake apn:work
120
65
 
@@ -128,6 +73,4 @@ worker.work(5)
128
73
  ## To run worker as daemon - NOT YET TESTED
129
74
  APN::SenderDaemon.new(ARGV).daemonize
130
75
 
131
- ## TESTING - check the broken pipe errors
132
76
 
133
- ## TESTING - then check implementation of resque-web
data/lib/apn.rb CHANGED
@@ -22,4 +22,6 @@ end
22
22
 
23
23
  require 'apn/notification'
24
24
  require 'apn/notification_job'
25
- require 'apn/sender'
25
+ require 'apn/connection/base'
26
+ require 'apn/sender'
27
+ require 'apn/feedback'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apn_sender
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2010-04-25 00:00:00 -07:00
12
+ date: 2010-05-02 00:00:00 -07:00
13
13
  default_executable:
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
@@ -54,6 +54,8 @@ files:
54
54
  - generators/templates/script
55
55
  - init.rb
56
56
  - lib/apn.rb
57
+ - lib/apn/connection/base.rb
58
+ - lib/apn/feedback.rb
57
59
  - lib/apn/notification.rb
58
60
  - lib/apn/notification_job.rb
59
61
  - lib/apn/queue_manager.rb