Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Basic functionality in place

  • Loading branch information...
commit f4aaa2cd03e38e4d7c8459ecc07b99d1aebdf0ad 1 parent 26f0566
@kdonovan kdonovan authored
View
2  LICENSE
@@ -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
22 README.rdoc
@@ -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
7 Rakefile
@@ -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
View
13 contrib/apn_sender.monitrc
@@ -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"
View
9 generators/apple_push_notification_generator.rb
@@ -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
View
7 generators/templates/script
@@ -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
1  init.rb
@@ -0,0 +1 @@
+require File.dirname(__FILE__) + "/rails/init.rb"
View
28 lib/apple_push_notification.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'
View
35 lib/apple_push_notification/message.rb
@@ -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
View
20 lib/apple_push_notification/message_job.rb
@@ -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
View
51 lib/apple_push_notification/queue_manager.rb
@@ -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
+
View
107 lib/apple_push_notification/sender.rb
@@ -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)
View
65 lib/apple_push_notification/sender_daemon.rb
@@ -0,0 +1,65 @@
+# Modified slightly from delayed_job's delayed/command.rb
+require 'rubygems'
+require 'daemons'
+require 'optparse'
+
+module ApplePushNotification
+ class SenderDaemon
+ attr_accessor :worker_count
+
+ def initialize(args)
+ @options = {:quiet => true}
+ @worker_count = 1
+
+ opts = OptionParser.new do |opts|
+ opts.banner = "Usage: #{File.basename($0)} [options] start|stop|restart|run"
+
+ opts.on('-h', '--help', 'Show this message') do
+ puts opts
+ exit 1
+ end
+ opts.on('-e', '--environment=NAME', 'Specifies the environment to run this apn_sender under ([development]/production).') do |e|
+ @options[:environment] = e
+ end
+ opts.on('--cert-path=NAME', '--certificate-path=NAME', 'Path to directory containing apn .pem certificates.') do |path|
+ @options[:cert_path] = path
+ end
+ opts.on('-n', '--number_of_workers=workers', "Number of unique workers to spawn") do |worker_count|
+ @worker_count = worker_count.to_i rescue 1
+ end
+ end
+ @args = opts.parse!(args)
+ end
+
+ def daemonize
+ worker_count.times do |worker_index|
+ process_name = worker_count == 1 ? "apn_sender" : "apn_sender.#{worker_index}"
+ Daemons.run_proc(process_name, :dir => "#{::RAILS_ROOT}/tmp/pids", :dir_mode => :normal, :ARGV => @args) do |*args|
+ run process_name
+ end
+ end
+ end
+
+ def run(worker_name = nil)
+ Dir.chdir(::RAILS_ROOT)
+ require File.join(::RAILS_ROOT, 'config', 'environment')
+
+ # Replace the default logger
+ logger = Logger.new(File.join(::RAILS_ROOT, 'log', 'apn_sender.log'))
+ logger.level = ActiveRecord::Base.logger.level
+ ActiveRecord::Base.logger = logger
+ ActiveRecord::Base.clear_active_connections!
+ ApplePushNotification::Sender.logger = logger
+
+ worker = ApplePushNotification::Sender.new(@options)
+ worker.verbose = @options[:verbose]
+ worker.very_verbose = @options[:very_verbose]
+ worker.work(@options[:delay] || 5)
+ rescue => e
+ logger.fatal e
+ STDERR.puts e.message
+ exit 1
+ end
+
+ end
+end
View
40 lib/apple_push_notification/tasks.rb
@@ -0,0 +1,40 @@
+# Slight modifications from the default Resque tasks
+namespace :apn do
+ task :setup
+ task :sender => :work
+ task :senders => :workers
+
+ desc "Start an ApplePushNotification worker"
+ task :work => :setup do
+ require 'lib/apple_push_notification'
+
+ worker = nil
+ queues = ApplePushNotification::QUEUE_NAME
+
+ begin
+ worker = Resque::Worker.new(queues)
+ worker.verbose = ENV['LOGGING'] || ENV['VERBOSE']
+ worker.very_verbose = ENV['VVERBOSE']
+ rescue Exception => e
+ raise e
+ # abort "set QUEUE env var, e.g. $ QUEUE=critical,high rake resque:work"
+ end
+
+ puts "*** Starting worker to send apple notifications in the background from #{worker}"
+
+ worker.work(ENV['INTERVAL'] || 5) # interval, will block
+ end
+
+ desc "Start multiple APN workers. Should only be used in dev mode."
+ task :workers do
+ threads = []
+
+ ENV['COUNT'].to_i.times do
+ threads << Thread.new do
+ system "rake apn:work"
+ end
+ end
+
+ threads.each { |thread| thread.join }
+ end
+end
View
30 lib/resque/hooks/before_unregister_worker.rb
@@ -0,0 +1,30 @@
+# Adding a +before_unregister_worker+ hook Resque::Worker. To be used, must be matched by a similar monkeypatch
+# for Resque class itself, or else a class that extends Resque. See apple_push_notification/queue_manager.rb for
+# an implementation.
+module Resque
+ class Worker
+ alias_method :unregister_worker_without_before_hook, :unregister_worker
@elight
elight added a note

Please don't do this!

Fork Resque, patch it to allow for an Array of handlers to be fired, and either provide access to that Array of handlers or mechanisms to pre/append to that list of handlers.

This monkey patch cost me at least 4 hours of sifting through code. It was only thanks to Pry that I eventually found it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+
+ # Wrapper for original unregister_worker method which adds a before hook +before_unregister_worker+
+ # to be executed if present.
+ def unregister_worker
+ run_hook(:before_unregister_worker, self)
+ unregister_worker_without_before_hook
+ end
+
+
+ # Unforunately have to override Resque::Worker's +run_hook+ method to call hook on
+ # ApplePushNotification::QueueManager rather on Resque directly. Any suggestions on
+ # how to make this more flexible are more than welcome.
+ def run_hook(name, *args)
+ # return unless hook = Resque.send(name)
+ return unless hook = ApplePushNotification::QueueManager.send(name)
+ msg = "Running #{name} hook"
+ msg << " with #{args.inspect}" if args.any?
+ log msg
+
+ args.any? ? hook.call(*args) : hook.call
+ end
+
+ end
+end
View
1  rails/init.rb
@@ -0,0 +1 @@
+require "apple_push_notification"
Please sign in to comment.
Something went wrong with that request. Please try again.