rapns 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # rapns [![Build Status](https://secure.travis-ci.org/ileitch/rapns.png)](http://travis-ci.org/ileitch/rapns)
2
+
3
+ Easy to use library for Apple's Push Notification Service with Rails 3.
4
+
5
+ ## Features
6
+
7
+ * Works with Rails 3 and Ruby 1.9.
8
+ * Uses a daemon process to keep open a persistent connection to the Push Notification Service, as recommended by Apple.
9
+ * Uses the [enhanced binary format](http://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW4) (Figure 5-2) so that delivery errors can be reported.
10
+ * [Airbrake](http://airbrakeapp.com/) (Hoptoad) integration.
11
+ * Support for [dictionary `alert` properties](http://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/ApplePushService/ApplePushService.html#//apple_ref/doc/uid/TP40008194-CH100-SW1) (Table 3-2).
12
+
13
+ ## Getting Started
14
+
15
+ Add rapns to your Gemfile:
16
+
17
+ gem 'rapns'
18
+
19
+ Generate the migration, rapns.yml and migrate:
20
+
21
+ rails g rapns
22
+ rake db:migrate
23
+
24
+ ## Generating Certificates
25
+
26
+ 1. Open up Keychain Access and select the `Certificates` category in the sidebar.
27
+ 2. Expand the disclosure arrow next to the iOS Push Services certificate you want to export.
28
+ 3. Select both the certificate and private key.
29
+ 4. Right click and select `Export 2 items...`.
30
+ 5. Save the file as `cert.p12`, make sure the File Format is `Personal Information Exchange (p12)`.
31
+ 6. If you decide to set a password for your exported certificate, please read the Configuration section below.
32
+ 7. Convert the certificate to a .pem, where `<environment>` should be `development` or `production`, depending on the certificate you exported.
33
+
34
+ `openssl pkcs12 -nodes -clcerts -in cert.p12 -out <environment>.pem`
35
+
36
+ 8. Move the .pem file into your Rails application under `config/rapns`.
37
+
38
+ ## Configuration
39
+
40
+ Environment configuration lives in `config/rapns/rapns.yml`. For common setups you probably wont need to change this file.
41
+
42
+ If you want to use rapns in environments other than development or production, you will need to create an entry for it. Simply duplicate the configuration for development or production, depending on which iOS Push Certificate you wish to use.
43
+
44
+ ### Options:
45
+
46
+ * `host` the APNs host to connect to, either `gateway.sandbox.push.apple.com` or `gateway.sandbox.push.apple.com`.
47
+ * `port` the APNs port. Currently 2195 for both hosts.
48
+ * `certificate` The path to your .pem certificate, `config/rapns` is automatically checked if a relative path is given.
49
+ * `certificate_password` (default: blank) the password you used when exporting your certificate, if any.
50
+ * `airbrake_notify` (default: true) Enables/disables error notifications via Airbrake.
51
+ * `poll` (default: 2) Frequency in seconds to check for new notifications to deliver.
52
+ * `connections` (default: 3) the number of connections to keep open to the APNs. Consider increasing this if you are sending a very large number of notifications.
53
+
54
+ ## Starting the rapns Daemon
55
+
56
+ cd /path/to/rails/app
57
+ bundle exec rapns <Rails environment>
58
+
59
+ ### Options
60
+
61
+ * `--foreground` will prevent rapns from forking into a daemon. Activity information will be printed to the screen.
62
+
63
+ ## Sending a Notification
64
+
65
+ n = Rapns::Notification.new
66
+ n.device_token = "934f7a..."
67
+ n.alert = "This is the message shown on the device."
68
+ n.badge = 1
69
+ n.sound = "1.aiff"
70
+ n.expiry = 1.day.to_i
71
+ n.attributes_for_device = {"question" => nil, "answer" => 42}
72
+ n.deliver_after = 1.hour.from_now
73
+ n.save!
74
+
75
+ * `sound` defaults to `1.aiff`. You can either set it to a custom .aiff file, or `nil` for no sound.
76
+ * `expiry` is the time in seconds the APNs will spend trying to deliver the notification to the device. The notification is discarded if it has not been delivered in this time. Default is 1 day.
77
+ * `attributes_for_device` is the `NSDictionary` argument passed to your iOS app in either `didFinishLaunchingWithOptions` or `didReceiveRemoteNotification`.
78
+ * `deliver_after` is not required, but may be set if you'd like to delay delivery of the notification to a specific time in the future.
79
+
80
+ ### Assigning a Hash to alert
81
+
82
+ Please refer to Apple's [documentation](http://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/ApplePushService/ApplePushService.html#//apple_ref/doc/uid/TP40008194-CH100-SW1) (Tables 3-1 and 3-2).
83
+
84
+ Not yet implemented!
85
+
86
+ ## Delivery Failures
87
+
88
+ The APN service provides two mechanism for delivery failure notification:
89
+
90
+ ### Immediately, when processing a notification for delivery.
91
+
92
+ Although rapns makes such errors highly unlikely due to validation, the APNs reports processing errors immediately after being sent a notification. These errors are all centred around the well-formedness of the notification payload. Should a notification be rejected due to such an error, rapns will update the following attributes on the notification and send a notification via Airbrake/Hoptoad (if enabled):
93
+
94
+ `failed` flag is set to true.
95
+ `failed_at` is set to the time of failure.
96
+ `error` is set to Apple's code for the error.
97
+ `error_description` is set to a (somewhat brief) description of the error.
98
+
99
+ rapns will not attempt to deliver the notification again.
100
+
101
+ ### Via the Feedback Service.
102
+
103
+ Not implemented yet!
104
+
105
+
data/bin/rapns ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+ require "rapns"
5
+ require "rapns/daemon"
6
+
7
+ foreground = false
8
+ environment = ARGV[0]
9
+ banner = "Usage: rapns <Rails environment> [options]"
10
+ ARGV.options do |opts|
11
+ opts.banner = banner
12
+ opts.on("-f", "--foreground", "Run in the foreground.") { foreground = true }
13
+ opts.on("-v", "--version", "Print this version of rapns.") { puts "rapns #{Rapns::VERSION}"; exit }
14
+ opts.on("-h", "--help", "You're looking at it.") { puts opts; exit }
15
+ opts.parse!
16
+ end
17
+
18
+ if environment.nil?
19
+ puts banner
20
+ exit 1
21
+ end
22
+
23
+ ENV["RAILS_ENV"] = environment
24
+ load "config/environment.rb"
25
+
26
+ Rapns::Daemon.start(environment, foreground)
@@ -0,0 +1,16 @@
1
+ class RapnsGenerator < Rails::Generators::Base
2
+ include Rails::Generators::Migration
3
+ source_root File.expand_path('../templates', __FILE__)
4
+
5
+ def self.next_migration_number(path)
6
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
7
+ end
8
+
9
+ def copy_migration
10
+ migration_template "create_rapns_notifications.rb", "db/migrate/create_rapns_notifications.rb"
11
+ end
12
+
13
+ def copy_config
14
+ copy_file "rapns.yml", "config/rapns/rapns.yml"
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ class CreateRapnsNotifications < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :rapns_notifications do |t|
4
+ t.integer :badge, :null => true
5
+ t.string :device_token, :null => false, :limit => 64
6
+ t.string :sound, :null => true, :default => "1.aiff"
7
+ t.string :alert, :null => true
8
+ t.text :attributes_for_device, :null => true
9
+ t.integer :expiry, :null => false, :default => 1.day.to_i
10
+ t.boolean :delivered, :null => false, :default => false
11
+ t.timestamp :delivered_at, :null => true
12
+ t.boolean :failed, :null => false, :default => false
13
+ t.timestamp :failed_at, :null => true
14
+ t.integer :error_code, :null => true
15
+ t.string :error_description, :null => true
16
+ t.timestamp :deliver_after, :null => true
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :rapns_notifications, [:delivered, :failed, :deliver_after], :name => "index_rapns_notifications_on_delivered_failed_deliver_after"
21
+ end
22
+
23
+ def self.down
24
+ drop_table :rapns_notifications
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ development:
2
+ host: gateway.sandbox.push.apple.com
3
+ port: 2195
4
+ certificate: development.pem
5
+ certificate_password:
6
+ airbrake_notify: true
7
+ poll: 2
8
+ connections: 3
9
+
10
+ production:
11
+ host: gateway.push.apple.com
12
+ port: 2195
13
+ certificate: production.pem
14
+ certificate_password:
15
+ airbrake_notify: true
16
+ poll: 2
17
+ connections: 3
@@ -0,0 +1,10 @@
1
+ module Rapns
2
+ class BinaryNotificationValidator < ActiveModel::Validator
3
+
4
+ def validate(record)
5
+ if record.to_binary(:for_validation => true).size > 256
6
+ record.errors[:base] << "APN notification cannot be larger than 256 bytes. Try condensing your alert and device attributes."
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,27 @@
1
+ module Rapns
2
+ class CertificateError < StandardError; end
3
+
4
+ module Daemon
5
+ class Certificate
6
+ attr_accessor :certificate
7
+
8
+ def initialize(certificate_path)
9
+ @certificate_path = certificate_path
10
+ end
11
+
12
+ def load
13
+ @certificate = read_certificate
14
+ end
15
+
16
+ protected
17
+
18
+ def read_certificate
19
+ if !File.exists?(@certificate_path)
20
+ raise CertificateError, "#{@certificate_path} does not exist. The certificate location can be configured in config/rapns/rapns.yml."
21
+ else
22
+ File.read(@certificate_path)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,69 @@
1
+ require "yaml"
2
+
3
+ module Rapns
4
+ class ConfigurationError < StandardError; end
5
+
6
+ module Daemon
7
+ class Configuration
8
+ attr_accessor :host, :port, :certificate, :certificate_password, :poll, :airbrake_notify, :connections
9
+ alias_method :airbrake_notify?, :airbrake_notify
10
+
11
+ def initialize(environment, config_path)
12
+ @environment = environment
13
+ @config_path = config_path
14
+ end
15
+
16
+ def load
17
+ config = read_config
18
+ ensure_environment_configured(config)
19
+ config = config[@environment]
20
+ set_variable(:host, config)
21
+ set_variable(:port, config)
22
+ set_variable(:certificate, config)
23
+ set_variable(:airbrake_notify, config, :optional => true, :default => true)
24
+ set_variable(:certificate_password, config, :optional => true, :default => "")
25
+ set_variable(:poll, config, :optional => true, :default => 2)
26
+ set_variable(:connections, config, :optional => true, :default => 3)
27
+ end
28
+
29
+ def certificate
30
+ if Pathname.new(@certificate).absolute?
31
+ @certificate
32
+ else
33
+ File.join(Rails.root, "config", "rapns", @certificate)
34
+ end
35
+ end
36
+
37
+ protected
38
+
39
+ def read_config
40
+ ensure_config_exists
41
+ File.open(@config_path) { |fd| YAML.load(fd) }
42
+ end
43
+
44
+ def set_variable(key, config, options = {})
45
+ if !config.key?(key.to_s) || config[key.to_s].to_s.strip == ""
46
+ if options[:optional]
47
+ instance_variable_set("@#{key}", options[:default])
48
+ else
49
+ raise Rapns::ConfigurationError, "'#{key}' not defined for environment '#{@environment}' in #{@config_path}"
50
+ end
51
+ else
52
+ instance_variable_set("@#{key}", config[key.to_s])
53
+ end
54
+ end
55
+
56
+ def ensure_config_exists
57
+ if !File.exists?(@config_path)
58
+ raise Rapns::ConfigurationError, "#{@config_path} does not exist. Have you run 'rails g rapns'?"
59
+ end
60
+ end
61
+
62
+ def ensure_environment_configured(config)
63
+ if !config.key?(@environment)
64
+ raise Rapns::ConfigurationError, "Configuration for environment '#{@environment}' not defined in #{@config_path}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,99 @@
1
+ module Rapns
2
+ module Daemon
3
+ class ConnectionError < StandardError; end
4
+
5
+ class Connection
6
+ SELECT_TIMEOUT = 0.5
7
+ ERROR_PACKET_BYTES = 6
8
+ APN_ERRORS = {
9
+ 1 => "Processing error",
10
+ 2 => "Missing device token",
11
+ 3 => "Missing topic",
12
+ 4 => "Missing payload",
13
+ 5 => "Missing token size",
14
+ 6 => "Missing topic size",
15
+ 7 => "Missing payload size",
16
+ 8 => "Invalid token",
17
+ 255 => "None (unknown error)"
18
+ }
19
+
20
+ def initialize(name)
21
+ @name = name
22
+ end
23
+
24
+ def connect
25
+ @ssl_context = setup_ssl_context
26
+ @tcp_socket, @ssl_socket = connect_socket
27
+ end
28
+
29
+ def close
30
+ @ssl_socket.close if @ssl_socket
31
+ @tcp_socket.close if @tcp_socket
32
+ end
33
+
34
+ def write(data)
35
+ retry_count = 0
36
+
37
+ begin
38
+ @ssl_socket.write(data)
39
+ @ssl_socket.flush
40
+
41
+ check_for_error
42
+ rescue Errno::EPIPE => e
43
+ Rapns::Daemon.logger.warn("[#{@name}] Lost connection to #{Rapns::Daemon.configuration.host}:#{Rapns::Daemon.configuration.port}, reconnecting...")
44
+ @tcp_socket, @ssl_socket = connect_socket
45
+
46
+ retry_count += 1
47
+
48
+ if retry_count < 3
49
+ sleep 1
50
+ retry
51
+ else
52
+ raise ConnectionError, "#{@name} tried #{retry_count} times to reconnect but failed: #{e.inspect}"
53
+ end
54
+ end
55
+ end
56
+
57
+ protected
58
+
59
+ def check_for_error
60
+ if IO.select([@ssl_socket], nil, nil, SELECT_TIMEOUT)
61
+ delivery_error = nil
62
+
63
+ if error = @ssl_socket.read(ERROR_PACKET_BYTES)
64
+ cmd, status, notification_id = error.unpack("ccN")
65
+
66
+ if cmd == 8 && status != 0
67
+ description = APN_ERRORS[status] || "Unknown error. Possible rapns bug?"
68
+ delivery_error = Rapns::DeliveryError.new(status, description, notification_id)
69
+ end
70
+ end
71
+
72
+ begin
73
+ Rapns::Daemon.logger.warn("[#{@name}] Error received, reconnecting...")
74
+ close
75
+ @tcp_socket, @ssl_socket = connect_socket
76
+ ensure
77
+ raise delivery_error if delivery_error
78
+ end
79
+ end
80
+ end
81
+
82
+ def setup_ssl_context
83
+ ssl_context = OpenSSL::SSL::SSLContext.new
84
+ ssl_context.key = OpenSSL::PKey::RSA.new(Rapns::Daemon.certificate.certificate, Rapns::Daemon.configuration.certificate_password)
85
+ ssl_context.cert = OpenSSL::X509::Certificate.new(Rapns::Daemon.certificate.certificate)
86
+ ssl_context
87
+ end
88
+
89
+ def connect_socket
90
+ tcp_socket = TCPSocket.new(Rapns::Daemon.configuration.host, Rapns::Daemon.configuration.port)
91
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, @ssl_context)
92
+ ssl_socket.sync = true
93
+ ssl_socket.connect
94
+ Rapns::Daemon.logger.info("[#{@name}] Connected to #{Rapns::Daemon.configuration.host}:#{Rapns::Daemon.configuration.port}")
95
+ [tcp_socket, ssl_socket]
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,31 @@
1
+ module Rapns
2
+ module Daemon
3
+ class ConnectionPool < Pool
4
+
5
+ def claim_connection
6
+ connection = nil
7
+ begin
8
+ connection = @queue.pop
9
+ yield connection
10
+ ensure
11
+ @queue.push(connection) if connection
12
+ end
13
+ connection
14
+ end
15
+
16
+ protected
17
+
18
+ def new_object_for_pool(i)
19
+ Connection.new("Connection #{i}")
20
+ end
21
+
22
+ def object_added_to_pool(object)
23
+ object.connect
24
+ end
25
+
26
+ def object_removed_from_pool(object)
27
+ object.close
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+ module Rapns
2
+ class DeliveryError < StandardError
3
+ attr_reader :code, :description, :notification_id
4
+
5
+ def initialize(code, description, notification_id)
6
+ @code = code
7
+ @description = description
8
+ @notification_id = notification_id
9
+ end
10
+
11
+ def message
12
+ "Unable to deliver notification #{notification_id}, received APN error #{code} (#{description})"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,53 @@
1
+ module Rapns
2
+ module Daemon
3
+ class DeliveryHandler
4
+ STOP = 0x666
5
+
6
+ def start
7
+ @thread = Thread.new do
8
+ loop do
9
+ break if @stop
10
+ handle_next_notification
11
+ end
12
+ end
13
+ end
14
+
15
+ def stop
16
+ @stop = true
17
+ end
18
+
19
+ protected
20
+
21
+ def handle_next_notification
22
+ begin
23
+ notification = Rapns::Daemon.delivery_queue.pop
24
+ return if notification == STOP
25
+
26
+ Rapns::Daemon.connection_pool.claim_connection do |connection|
27
+ begin
28
+ connection.write(notification.to_binary)
29
+
30
+ notification.delivered = true
31
+ notification.delivered_at = Time.now
32
+ notification.save!(:validate => false)
33
+
34
+ Rapns::Daemon.logger.info("Notification #{notification.id} delivered to #{notification.device_token}")
35
+ rescue Rapns::DeliveryError => error
36
+ Rapns::Daemon.logger.error(error)
37
+
38
+ notification.delivered = false
39
+ notification.delivered_at = nil
40
+ notification.failed = true
41
+ notification.failed_at = Time.now
42
+ notification.error_code = error.code
43
+ notification.error_description = error.description
44
+ notification.save!(:validate => false)
45
+ end
46
+ end
47
+ rescue StandardError => e
48
+ Rapns::Daemon.logger.error(e)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ module Rapns
2
+ module Daemon
3
+ class DeliveryHandlerPool < Pool
4
+
5
+ protected
6
+
7
+ def new_object_for_pool(i)
8
+ DeliveryHandler.new
9
+ end
10
+
11
+ def object_added_to_pool(object)
12
+ object.start
13
+ end
14
+
15
+ def object_removed_from_pool(object)
16
+ object.stop
17
+ end
18
+
19
+ def drain_started
20
+ @num_objects.times { Rapns::Daemon.delivery_queue.push(Rapns::Daemon::DeliveryHandler::STOP) }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,31 @@
1
+ module Rapns
2
+ module Daemon
3
+ class Feeder
4
+ def self.start
5
+ @thread = Thread.new do
6
+ loop do
7
+ break if @stop
8
+ enqueue_notifications
9
+ end
10
+ end
11
+ @thread.join
12
+ end
13
+
14
+ def self.enqueue_notifications
15
+ begin
16
+ Rapns::Notification.ready_for_delivery.each do |notification|
17
+ Rapns::Daemon.delivery_queue.push(notification)
18
+ end
19
+ rescue StandardError => e
20
+ Rapns::Daemon.logger.error(e)
21
+ end
22
+
23
+ sleep Rapns::Daemon.configuration.poll
24
+ end
25
+
26
+ def self.stop
27
+ @stop = true
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ module Rapns
2
+ module Daemon
3
+ class Logger
4
+ def initialize(options)
5
+ @options = options
6
+ log_path = File.join(Rails.root, "log", "rapns.log")
7
+ @logger = ActiveSupport::BufferedLogger.new(log_path, Rails.logger.level)
8
+ @logger.auto_flushing = Rails.logger.auto_flushing
9
+ end
10
+
11
+ def info(msg)
12
+ log(:info, msg)
13
+ end
14
+
15
+ def error(msg)
16
+ airbrake_notify(msg) if msg.is_a?(Exception)
17
+ log(:error, msg, "ERROR")
18
+ end
19
+
20
+ def warn(msg)
21
+ log(:warn, msg, "WARNING")
22
+ end
23
+
24
+ private
25
+
26
+ def log(where, msg, prefix = nil)
27
+ if msg.is_a?(Exception)
28
+ msg = "#{msg.class.name}, #{msg.message}"
29
+ end
30
+
31
+ formatted_msg = "[#{Time.now.to_s(:db)}] "
32
+ formatted_msg << "[#{prefix}] " if prefix
33
+ formatted_msg << msg
34
+ puts formatted_msg if @options[:foreground]
35
+ @logger.send(where, formatted_msg)
36
+ end
37
+
38
+ def airbrake_notify(e)
39
+ return unless @options[:airbrake_notify] == true
40
+
41
+ if defined?(Airbrake)
42
+ Airbrake.notify(e)
43
+ elsif defined?(HoptoadNotifier)
44
+ HoptoadNotifier.notify(e)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,41 @@
1
+ module Rapns
2
+ module Daemon
3
+ class Pool
4
+ def initialize(num_objects)
5
+ @num_objects = num_objects
6
+ @queue = Queue.new
7
+ end
8
+
9
+ def populate
10
+ @num_objects.times do |i|
11
+ object = new_object_for_pool(i)
12
+ @queue.push(object)
13
+ object_added_to_pool(object)
14
+ end
15
+ end
16
+
17
+ def drain
18
+ drain_started
19
+
20
+ while !@queue.empty?
21
+ object = @queue.pop
22
+ object_removed_from_pool(object)
23
+ end
24
+ end
25
+
26
+ protected
27
+
28
+ def new_object_for_pool(i)
29
+ end
30
+
31
+ def object_added_to_pool(object)
32
+ end
33
+
34
+ def object_removed_from_pool(object)
35
+ end
36
+
37
+ def drain_started
38
+ end
39
+ end
40
+ end
41
+ end