Permalink
Browse files

Basic functionality in place

  • Loading branch information...
1 parent 26f0566 commit f4aaa2cd03e38e4d7c8459ecc07b99d1aebdf0ad @kdonovan kdonovan committed Apr 13, 2010
View
@@ -1,4 +1,4 @@
-Copyright (c) 2009 Kali Donovan
+Copyright (c) 2010 Kali Donovan
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
View
@@ -2,6 +2,28 @@
Description goes here.
+== Usage
+
+=== Sending Messages to Background
+
+To send a background message through Apple's Push Notification service from your Rails application:
+
+ ApplePushNotification.send_message(token, opts_hash)
+
+where +token+ is the unique identifier of the iPhone to receive the message and +opts_hash+ can have any of the following keys:
+
+ # :alert #=> The alert to send
+ # :badge #=> The badge number to send
+ # :sound #=> The sound file to play on receipt, or +true+ to play the default sound installed with your app
+
+=== Getting Messages Actually Sent
+
+Put your +apn_development.pem+ and +apn_production.pem+ certificates from Apple in your RAILS_ROOT/config/certs directory.
+
+
+
+
+
== Note on Patches/Pull Requests
* Fork the project.
View
@@ -1,16 +1,19 @@
require 'rubygems'
require 'rake'
+load 'lib/apple_push_notification/tasks.rb'
+
begin
require 'jeweler'
Jeweler::Tasks.new do |gem|
gem.name = "apple_push_notification"
gem.summary = %Q{Resque-based background worker to send Apple Push Notifications over a persistent TCP socket.}
- gem.description = %Q{TODO: longer description of your gem}
+ gem.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 messages, and a background daemon to send them.}
gem.email = "kali.donovan@gmail.com"
gem.homepage = "http://github.com/kdonovan/apple_push_notification"
gem.authors = ["Kali Donovan"]
- gem.add_development_dependency "thoughtbot-shoulda", ">= 0"
+ gem.add_dependency 'resque'
+ gem.add_dependency 'resque-access_worker_from_job'
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
end
Jeweler::GemcutterTasks.new
@@ -0,0 +1,13 @@
+# an example Monit configuration file for running the apple_push_notification background sender
+#
+# To use:
+# 1. copy to /var/www/apps/{app_name}/shared/apn_sender.monitrc
+# 2. replace {app_name} as appropriate
+# 3. add this to your /etc/monit/monitrc
+#
+# include /var/www/apps/{app_name}/shared/apn_sender.monitrc
+
+check process apn_sender
+ with pidfile /var/www/apps/{app_name}/shared/pids/apn_sender.pid
+ start program = "/usr/bin/env RAILS_ENV=production /var/www/apps/{app_name}/current/script/apn_sender start"
+ stop program = "/usr/bin/env RAILS_ENV=production /var/www/apps/{app_name}/current/script/apn_sender stop"
@@ -0,0 +1,9 @@
+class ApplePushNotificationGenerator < Rails::Generator::Base
+
+ def manifest
+ record do |m|
+ m.template 'script', 'script/apn_sender', :chmod => 0755
+ end
+ end
+
+end
@@ -0,0 +1,7 @@
+#!/usr/bin/env ruby
+
+# Daemons sets pwd to /, so we have to explicitly set RAILS_ROOT
+RAILS_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
+
+require File.join(File.dirname(__FILE__), *%w(.. vendor plugins apple_push_notification lib apple_push_notification sender_daemon))
+ApplePushNotification::SenderDaemon.new(ARGV).daemonize
View
@@ -0,0 +1 @@
+require File.dirname(__FILE__) + "/rails/init.rb"
@@ -0,0 +1,28 @@
+$:.unshift(File.dirname(__FILE__)) unless
+ $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
+
+require 'resque'
+require 'resque/plugins/access_worker_from_job'
+require File.dirname(__FILE__) + '/resque/hooks/before_unregister_worker'
+
+begin
+ require 'yajl/json_gem'
+rescue LoadError
+ require 'json'
+end
+
+require 'apple_push_notification/queue_manager'
+
+module ApplePushNotification
+ # Change this to modify the queue message jobs are pushed to and pulled from
+ QUEUE_NAME = :apple_push_notifications
+
+ # Enqueues a message to be sent in the background via the persistent TCP socket, assuming apn_sender is running (or will be soon)
+ def self.send_message(token, opts = {})
+ ApplePushNotification::QueueManager.enqueue(ApplePushNotification::MessageJob, token, opts)
+ end
+end
+
+require 'apple_push_notification/message'
+require 'apple_push_notification/message_job'
+require 'apple_push_notification/sender'
@@ -0,0 +1,35 @@
+module ApplePushNotification
+ # Encapsulates the logic necessary to convert an iPhone token and an array of options into a string of the format required
+ # by Apple's servers to send the notification. Much of the processing code here copied with many thanks from
+ # http://github.com/samsoffes/apple_push_notification/blob/master/lib/apple_push_notification.rb
+ #
+ # Message.new's first argument is the token of the iPhone which should receive the message. The second argument is a hash
+ # with any of :alert, :badge, and :sound keys. All three accept string arguments, while :sound can also be set to +true+ to
+ # play the default sound installed with the application.
+ class Message
+ attr_accessor :message
+ def initialize(token, options)
+ json = apple_json_array(options)
+ hex_token = [token.delete(' ')].pack('H*')
+ @message = "\0\0 #{hex_token}\0#{json.length.chr}#{json}"
+ raise "The maximum size allowed for a notification payload is 256 bytes." if @message.size.to_i > 256
+ end
+
+ def to_s
+ @message
+ end
+
+ protected
+
+ def apple_json_array(options)
+ result = {}
+ result['aps'] = {}
+ result['aps']['alert'] = options[:alert].to_s if options[:alert]
+ result['aps']['badge'] = options[:badge].to_i if options[:badge]
+ result['aps']['sound'] = options[:sound] if options[:sound] and options[:sound].is_a? String
+ result['aps']['sound'] = 'default' if options[:sound] and options[:sound].is_a? TrueClass
+ result.to_json
+ end
+ end
+
+end
@@ -0,0 +1,20 @@
+module ApplePushNotification
+ # This is the class that's actually enqueued via Resque when user calls +ApplePushNotification.send_message+.
+ # It gets added to the +apple_server_notifications+ Resque queue, which should only be operated on by
+ # workers of the +ApplePushNotification::Sender+ class.
+ class MessageJob
+ # Behind the scenes, this is the name of our Resque queue
+ @queue = ApplePushNotification::QUEUE_NAME
+
+ # Only execute this job in specialized ApplePushNotification::Sender workers, since
+ # standard Resque workers don't maintain the persistent TCP connection.
+ extend Resque::Plugins::AccessWorkerFromJob
+ self.required_worker_class = 'ApplePushNotification::Sender'
+
+ # The worker name is pushed on the end of the argument list by the APN Resque extensions
+ def self.perform(*args)
+ worker = args.pop
+ worker.send_to_apple( AppleServerNotification::Message.new(args) )
+ end
+ end
+end
@@ -0,0 +1,51 @@
+# Extending Resque to respond to the +before_unregister_worker+ hook. Note this requires a matching
+# monkeypatch in the Resque::Worker class. See +resque/hooks/before_unregister_worker.rb+ for an
+# example implementation
+
+module ApplePushNotification
+ # Extends Resque, allowing us to add all the callbacks to Resque we desire without affecting the expected
+ # functionality in the parent app, if we're included in e.g. a Rails application.
+ class QueueManager
+ extend Resque
+
+ def self.before_unregister_worker(&block)
+ block ? (@before_unregister_worker = block) : @before_unregister_worker
+ end
+
+ def self.before_unregister_worker=(before_unregister_worker)
+ @before_unregister_worker = before_unregister_worker
+ end
+
+ def self.to_s
+ "ApplePushNotification::QueueManager (Resque Client) connected to #{redis.server}"
+ end
+ end
+
+end
+
+# Ensures we close any open sockets when the worker exits
+ApplePushNotification::QueueManager.before_unregister_worker do |worker|
+ worker.send(:teardown_connection) if worker.respond_to?(:teardown_connection)
+end
+
+
+# # Run N jobs per fork, rather than creating a new fork for each message
+# # By defunkt - http://gist.github.com/349376
+# ApplePushNotification::QueueManager.after_fork do |job|
+# # How many jobs should we process in each fork?
+# jobs_per_fork = 10
+#
+# # Set hook to nil to prevent running this hook over
+# # and over while processing more jobs in this fork.
+# Resque.after_fork = nil
+#
+# # Make sure we process jobs in the right order.
+# job.worker.process(job)
+#
+# # One less than specified because the child will run a
+# # final job after exiting this hook.
+# (jobs_per_fork.to_i - 1).times do
+# job.worker.process
+# end
+# end
+
@@ -0,0 +1,107 @@
+require 'socket'
+require 'openssl'
+require 'resque'
+
+module ApplePushNotification
+ # Subclass of Resque::Worker which initializes a single TCP socket on creation to communicate with Apple's Push Notification servers.
+ # Shares this socket with each child process forked off by Resque to complete a job. Socket is closed in the before_unregister_worker
+ # callback, which gets called on normal or exceptional exits.
+ #
+ # End result: single persistent TCP connection to Apple, so they don't ban you for frequently opening and closing connections,
+ # which they apparently view as "spammy".
+ #
+ # Accepts :environment (production vs anything else) and :cert_path options on initialization. If called in a Rails context
+ # will default to RAILS_ENV and RAILS_ROOT/config/certs. :environment will default to development. ApplePushNotification::Sender
+ # expects two files to exist in the specified :cert_path directory: apn_production.pem and apn_development.pem.
+ class Sender < ::Resque::Worker
+ APN_PORT = 2195
+ attr_accessor :apn_cert, :apn_host, :socket, :socket_tcp, :opts
+
+ class << self
+ attr_accessor :logger
+ end
+
+ self.logger = if defined?(Merb::Logger)
+ Merb.logger
+ elsif defined?(RAILS_DEFAULT_LOGGER)
+ RAILS_DEFAULT_LOGGER
+ end
+
+ def initialize(opts = {})
+ @opts = opts
+
+ # Set option defaults
+ @opts[:cert_path] ||= File.join(File.expand_path(RAILS_ROOT), "config", "certs") if defined?(RAILS_ROOT)
+ @opts[:environment] ||= RAILS_ENV if defined?(RAILS_ENV)
+
+ logger.info "ApplePushNotification::Sender initializing. Establishing connections first..." if @opts[:verbose]
+ setup_paths
+ setup_connection
+
+ super( ApplePushNotification::QUEUE_NAME )
+ end
+
+ # Send a raw string over the socket to Apple's servers (presumably already formatted by ApplePushNotification::Message)
+ def send_to_apple(msg)
+ @socket.write( msg )
+ rescue SocketError => error
+ logger.error("Error with connection to #{@apn_host}: #{error}")
+ raise "Error with connection to #{@apn_host}: #{error}"
+ end
+
+ protected
+
+ def apn_production?
+ @opts[:environment] && @opts[:environment] != '' && :production == @opts[:environment].to_sym
+ end
+
+ # Get a fix on the .pem certificate we'll be using for SSL
+ def setup_paths
+ raise "Missing certificate path. Please specify :cert_path when initializing class." unless @opts[:cert_path]
+ @apn_host = apn_production? ? "gateway.push.apple.com" : "gateway.sandbox.push.apple.com"
+ cert_name = apn_production? ? "apn_production.pem" : "apn_development.pem"
+ cert_path = File.join(@opts[:cert_path], cert_name)
+
+ @apn_cert = File.exists?(cert_name) ? File.read(cert_name) : nil
+ raise "Missing apple push notification certificate in #{cert_path}" unless @apn_cert
+ end
+
+ # Open socket to Apple's servers
+ def setup_connection
+ raise "Missing apple push notification certificate" unless @apn_cert
+ raise "Trying to open already-open socket" if @socket || @socket_tcp
+
+ ctx = OpenSSL::SSL::SSLContext.new
+ ctx.key = OpenSSL::PKey::RSA.new(@apn_cert)
+ ctx.cert = OpenSSL::X509::Certificate.new(@apn_cert)
+
+ @socket_tcp = TCPSocket.new(@apn_host, APN_PORT)
+ @socket = OpenSSL::SSL::SSLSocket.new(@socket_tcp, ctx)
+ @socket.sync = true
+ @socket.connect
+ rescue SocketError => error
+ logger.error("Error with connection to #{@apn_host}: #{error}")
+ raise "Error with connection to #{@apn_host}: #{error}"
+ end
+
+ # Close open sockets
+ def teardown_connection
+ logger.info "Closing connections..." if @opts[:verbose]
+ @socket.close if @socket
+ @socket_tcp.close if @socket_tcp
+ end
+
+ end
+
+end
+
+
+__END__
+
+# nc -l -p 1234 localhost
+
+Resque.workers.map(&:unregister_worker)
+require 'ruby-debug'
+worker = ApplePushNotification::Sender.new(:cert_path => './certs/')
+worker.very_verbose = true
+worker.work(5)
Oops, something went wrong.

0 comments on commit f4aaa2c

Please sign in to comment.