Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

initial commit

  • Loading branch information...
commit 94a5b96b58c279b624de8ead610b14f1ece4dc26 0 parents
@tompesman authored
4 .gitignore
@@ -0,0 +1,4 @@
+.bundle/
+log/*.log
+pkg/
+Gemfile.lock
16 Gemfile
@@ -0,0 +1,16 @@
+source "http://rubygems.org"
+
+# Declare your gem's dependencies in push-apns.gemspec.
+# Bundler will treat runtime dependencies like base dependencies, and
+# development dependencies will be added by default to the :development group.
+gemspec
+
+# Declare any dependencies that are still in development here instead of in
+# your gemspec. These might include edge Rails or gems from your path or
+# Git. Remember to move these dependencies to your gemspec before releasing
+# your gem to rubygems.org.
+
+# To use debugger
+# gem 'ruby-debug19', :require => 'ruby-debug'
+
+gem 'push-core', :path => "~/code/push/push-core"
20 MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright 2012 YOURNAME
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
3  README.md
@@ -0,0 +1,3 @@
+= PushApns
+
+This project rocks and uses MIT-LICENSE.
11 lib/push-apns.rb
@@ -0,0 +1,11 @@
+require 'socket'
+require 'pathname'
+require 'push-apns/version'
+require 'push/apns/binary_notification_validator'
+require 'push/message_apns'
+require 'push/feedback_apns'
+require 'push/daemon/apns'
+require 'push/daemon/interruptible_sleep'
+require 'push/daemon/apns_support/certificate'
+require 'push/daemon/apns_support/connection_apns'
+require 'push/daemon/apns_support/feedback_receiver'
3  lib/push-apns/version.rb
@@ -0,0 +1,3 @@
+module PushApns
+ VERSION = "0.0.1.pre"
+end
12 lib/push/apns/binary_notification_validator.rb
@@ -0,0 +1,12 @@
+module Push
+ module Apns
+ class BinaryNotificationValidator < ActiveModel::Validator
+
+ def validate(record)
+ if record.payload_size > 256
+ record.errors[:base] << "APN notification cannot be larger than 256 bytes. Try condensing your alert and device attributes."
+ end
+ end
+ end
+ end
+end
41 lib/push/daemon/apns.rb
@@ -0,0 +1,41 @@
+module Push
+ module Daemon
+ class Apns
+ attr_accessor :configuration, :certificate
+
+ def initialize(options)
+ self.configuration = options
+
+ self.certificate = ApnsSupport::Certificate.new(configuration[:certificate])
+ certificate.load
+
+ start_feedback
+ end
+
+ def pushconnections
+ self.configuration[:connections]
+ end
+
+ def totalconnections
+ # + feedback
+ pushconnections + 1
+ end
+
+ def connectiontype
+ ApnsSupport::ConnectionApns
+ end
+
+ def start_feedback
+ ApnsSupport::FeedbackReceiver.start(self)
+ end
+
+ def stop_feedback
+ ApnsSupport::FeedbackReceiver.stop
+ end
+
+ def stop
+ stop_feedback
+ end
+ end
+ end
+end
37 lib/push/daemon/apns_support/certificate.rb
@@ -0,0 +1,37 @@
+module Push
+ class CertificateError < StandardError; end
+
+ module Daemon
+ module ApnsSupport
+ class Certificate
+ attr_accessor :certificate
+
+ def initialize(certificate_path)
+ @certificate_path = path(certificate_path)
+ end
+
+ def path(path)
+ if Pathname.new(path).absolute?
+ path
+ else
+ File.join(Rails.root, "config", "push", path)
+ end
+ end
+
+ def load
+ @certificate = read_certificate
+ end
+
+ protected
+
+ def read_certificate
+ if !File.exists?(@certificate_path)
+ raise CertificateError, "#{@certificate_path} does not exist. The certificate location can be configured in config/push/<<environment>>.rb"
+ else
+ File.read(@certificate_path)
+ end
+ end
+ end
+ end
+ end
+end
96 lib/push/daemon/apns_support/connection_apns.rb
@@ -0,0 +1,96 @@
+module Push
+ module Daemon
+ module ApnsSupport
+ class ConnectionApns
+ attr_reader :name, :provider
+
+ def initialize(provider, i=nil)
+ @provider = provider
+ if i
+ # Apns push connection
+ @name = "ConnectionApns #{i}"
+ @host = "gateway.#{provider.configuration[:sandbox] ? 'sandbox.' : ''}push.apple.com"
+ @port = 2195
+ else
+ @name = "FeedbackReceiver"
+ @host = "feedback.#{provider.configuration[:sandbox] ? 'sandbox.' : ''}push.apple.com"
+ @port = 2196
+ end
+ end
+
+ def connect
+ @ssl_context = setup_ssl_context
+ @tcp_socket, @ssl_socket = connect_socket
+ end
+
+ def close
+ begin
+ @ssl_socket.close if @ssl_socket
+ @tcp_socket.close if @tcp_socket
+ rescue IOError
+ end
+ end
+
+ def read(num_bytes)
+ @ssl_socket.read(num_bytes)
+ end
+
+ def select(timeout)
+ IO.select([@ssl_socket], nil, nil, timeout)
+ end
+
+ def write(data)
+ retry_count = 0
+
+ begin
+ write_data(data)
+ rescue Errno::EPIPE, Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
+ retry_count += 1;
+
+ if retry_count == 1
+ Push::Daemon.logger.error("[#{@name}] Lost connection to #{@host}:#{@port} (#{e.class.name}), reconnecting...")
+ end
+
+ if retry_count <= 3
+ reconnect
+ sleep 1
+ retry
+ else
+ raise ConnectionError, "#{@name} tried #{retry_count-1} times to reconnect but failed (#{e.class.name})."
+ end
+ end
+ end
+
+ def reconnect
+ close
+ @tcp_socket, @ssl_socket = connect_socket
+ end
+
+ protected
+
+ def write_data(data)
+ @ssl_socket.write(data)
+ @ssl_socket.flush
+ end
+
+ def setup_ssl_context
+ ssl_context = OpenSSL::SSL::SSLContext.new
+ ssl_context.key = OpenSSL::PKey::RSA.new(provider.certificate.certificate, provider.configuration[:certificate_password])
+ ssl_context.cert = OpenSSL::X509::Certificate.new(provider.certificate.certificate)
+ ssl_context
+ end
+
+ def connect_socket
+ tcp_socket = TCPSocket.new(@host, @port)
+ tcp_socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, 1)
+ tcp_socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, @ssl_context)
+ ssl_socket.sync = true
+ ssl_socket.connect
+ Push::Daemon.logger.info("[#{@name}] Connected to #{@host}:#{@port}")
+ [tcp_socket, ssl_socket]
+ end
+ end
+ end
+ end
+end
58 lib/push/daemon/apns_support/feedback_receiver.rb
@@ -0,0 +1,58 @@
+module Push
+ module Daemon
+ module ApnsSupport
+ class FeedbackReceiver
+ extend Push::Daemon::InterruptibleSleep
+ attr_accessor :provider
+ FEEDBACK_TUPLE_BYTES = 38
+
+ def self.start(provider)
+ @provider = provider
+ @thread = Thread.new do
+ loop do
+ break if @stop
+ check_for_feedback
+ interruptible_sleep @provider.configuration[:feedback_poll]
+ end
+ end
+ end
+
+ def self.stop
+ @stop = true
+ interrupt_sleep
+ @thread.join if @thread
+ end
+
+ def self.check_for_feedback
+ connection = nil
+ begin
+ connection = ApnsSupport::ConnectionApns.new(@provider)
+ connection.connect
+
+ while tuple = connection.read(FEEDBACK_TUPLE_BYTES)
+ timestamp, device = parse_tuple(tuple)
+ create_feedback(timestamp, device)
+ end
+ rescue StandardError => e
+ Push::Daemon.logger.error(e)
+ ensure
+ connection.close if connection
+ end
+ end
+
+ protected
+
+ def self.parse_tuple(tuple)
+ failed_at, _, device = tuple.unpack("N1n1H*")
+ [Time.at(failed_at).utc, device]
+ end
+
+ def self.create_feedback(failed_at, device)
+ formatted_failed_at = failed_at.strftime("%Y-%m-%d %H:%M:%S UTC")
+ Push::Daemon.logger.info("[FeedbackReceiver] Delivery failed at #{formatted_failed_at} for #{device}")
+ Push::FeedbackApns.create!(:failed_at => failed_at, :device => device)
+ end
+ end
+ end
+ end
+end
5 lib/push/feedback_apns.rb
@@ -0,0 +1,5 @@
+module Push
+ class FeedbackApns < Push::Feedback
+ validates :device, :format => { :with => /\A[a-z0-9]{64}\z/ }
+ end
+end
101 lib/push/message_apns.rb
@@ -0,0 +1,101 @@
+module Push
+ class MessageApns < Push::Message
+ SELECT_TIMEOUT = 0.5
+ ERROR_TUPLE_BYTES = 6
+ APN_ERRORS = {
+ 1 => "Processing error",
+ 2 => "Missing device token",
+ 3 => "Missing topic",
+ 4 => "Missing payload",
+ 5 => "Missing token size",
+ 6 => "Missing topic size",
+ 7 => "Missing payload size",
+ 8 => "Invalid token",
+ 255 => "None (unknown error)"
+ }
+
+ store :properties, accessors: [:alert, :badge, :sound, :expiry, :attributes_for_device]
+
+ validates :badge, :numericality => true, :allow_nil => true
+ validates :expiry, :numericality => true, :presence => true
+ validates :device, :format => { :with => /\A[a-z0-9]{64}\z/ }
+ validates_with Push::Apns::BinaryNotificationValidator
+
+ # def attributes_for_device=(attrs)
+ # raise ArgumentError, "attributes_for_device must be a Hash" if !attrs.is_a?(Hash)
+ # write_attribute(:attributes_for_device, MultiJson.encode(attrs))
+ # end
+ #
+ # def attributes_for_device
+ # MultiJson.decode(read_attribute(:attributes_for_device)) if read_attribute(:attributes_for_device)
+ # end
+
+ def alert=(alert)
+ if alert.is_a?(Hash)
+ #write_attribute(:alert, MultiJson.encode(alert))
+ properties[:alert] = MultiJson.encode(alert)
+ else
+ #write_attribute(:alert, alert)
+ properties[:alert] = alert
+ end
+ end
+
+ def alert
+ string_or_json = read_attribute(:alert)
+ MultiJson.decode(string_or_json) rescue string_or_json
+ end
+
+ # This method conforms to the enhanced binary format.
+ # http://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW4
+ def to_message(options = {})
+ id_for_pack = options[:for_validation] ? 0 : id
+ [1, id_for_pack, expiry, 0, 32, device, 0, payload_size, payload].pack("cNNccH*cca*")
+ end
+
+ def use_connection
+ Push::Daemon::ApnsSupport::ConnectionApns
+ end
+
+ def payload
+ MultiJson.encode(as_json)
+ end
+
+ def payload_size
+ payload.bytesize
+ end
+
+ private
+
+ def as_json
+ json = ActiveSupport::OrderedHash.new
+ json['aps'] = ActiveSupport::OrderedHash.new
+ json['aps']['alert'] = alert if alert
+ json['aps']['badge'] = badge if badge
+ json['aps']['sound'] = sound if sound
+ attributes_for_device.each { |k, v| json[k.to_s] = v.to_s } if attributes_for_device
+ json
+ end
+
+ def check_for_error(connection)
+ if connection.select(SELECT_TIMEOUT)
+ error = nil
+
+ if tuple = connection.read(ERROR_TUPLE_BYTES)
+ cmd, code, notification_id = tuple.unpack("ccN")
+
+ description = APN_ERRORS[code.to_i] || "Unknown error. Possible push bug?"
+ error = Push::DeliveryError.new(code, notification_id, description, "APNS")
+ else
+ error = Push::DisconnectionError.new
+ end
+
+ begin
+ Push::Daemon.logger.error("[#{connection.name}] Error received, reconnecting...")
+ connection.reconnect
+ ensure
+ raise error if error
+ end
+ end
+ end
+ end
+end
24 push-apns.gemspec
@@ -0,0 +1,24 @@
+$:.push File.expand_path("../lib", __FILE__)
+
+# Maintain your gem's version:
+require "push-apns/version"
+
+# Describe your gem and declare its dependencies:
+Gem::Specification.new do |s|
+ s.name = "push-apns"
+ s.version = PushApns::VERSION
+ s.authors = ["Tom Pesman"]
+ s.email = ["tom@tnux.net"]
+ s.homepage = "https://github.com/tompesman/push-apns"
+ s.summary = "APNS (iOS) part of the modular push daemon."
+ s.description = "Plugin with APNS specific push information."
+
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
+ s.files = `git ls-files lib`.split("\n") + ["README.md", "MIT-LICENSE"]
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
+ s.require_paths = ["lib"]
+
+ s.add_dependency "multi_json", "~> 1.0"
+ s.add_dependency "push-core", "0.0.1.pre"
+ s.add_development_dependency "sqlite3"
+end
Please sign in to comment.
Something went wrong with that request. Please try again.