apn 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in apn.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Peter Hrbacik
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # APN
2
+
3
+ APN is a lightweight Apple Push Notification daemon for Ruby on Rails. APN runs as a daemon, works asynchronously using Redis and keeps persistent connection to Apple Push Notification Server.
4
+
5
+ ## Prerequisites
6
+
7
+ APN works asynchronously, queueing messages from Redis, so you will need to have a running instance of the Redis server.
8
+
9
+ [Installation guide for Redis server](http://redis.io/topics/quickstart)
10
+
11
+ You will also need certificate file from Apple to be able to communicate with their server.
12
+ There are many guides how to do that.
13
+
14
+ ## Installation
15
+
16
+ Add this line to your application's Gemfile:
17
+
18
+ gem 'apn'
19
+
20
+ And then execute:
21
+
22
+ $ bundle
23
+
24
+ Or install it yourself as:
25
+
26
+ $ gem install apn
27
+
28
+ ## Daemon usage
29
+
30
+ Run daemon within your root Rails directory with this command:
31
+
32
+ ```
33
+ Usage: apn start|stop|run [options]
34
+ --cert_file=MANDATORY Location of the cert pem file
35
+ --cert_password=OPTIONAL Password for the cert pem file
36
+ --redis_host=OPTIONAL Redis hostname
37
+ --redis_port=OPTIONAL Redis port
38
+ --queue=OPTIONAL Name of the Redis queue
39
+ --log_file=OPTIONAL Log file (default STDOUT)
40
+ --help Show help
41
+ ```
42
+
43
+ You can use ```apn stop``` instead of start in order to kill a running daemon with the options you provide.
44
+
45
+ ## Client usage
46
+
47
+ ```ruby
48
+ require 'apn'
49
+
50
+ message = {:alert => 'This is a test from APN!', :badge => 16}
51
+ APN.queue(message)
52
+ ```
53
+
54
+ If you want to configure APN, just add configuration block.
55
+
56
+ ```ruby
57
+ require 'apn'
58
+
59
+ APN.configure do |config|
60
+ config.redis_host = 'localhost'
61
+ config.redis_port = 6379
62
+ end
63
+
64
+ message = {:alert => 'This is a test from APN!', :badge => 16}
65
+ APN.queue(message)
66
+ ```
67
+
68
+ ## Contributing
69
+
70
+ 1. Fork it
71
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
72
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
73
+ 4. Push to the branch (`git push origin my-new-feature`)
74
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/apn.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'apn/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "apn"
8
+ spec.version = APN::VERSION
9
+ spec.authors = ["Peter Hrbacik"]
10
+ spec.email = ["info@itrinity.com"]
11
+ spec.description = 'APN is a lightweight Apple Push Notification daemon for Ruby on Rails. APN works asynchronously using Redis and run as a daemon.'
12
+ spec.summary = 'Asynchronous Apple Push Notification daemon'
13
+ spec.homepage = "http://github.com/itrinity/apn"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables << "apn"
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.3"
22
+ spec.add_development_dependency "rake"
23
+
24
+ spec.add_dependency(%q<redis>, ["~> 2.2.2"])
25
+ spec.add_dependency(%q<activesupport>, [">= 0"])
26
+ spec.add_dependency(%q<daemons>, ["= 1.1.6"])
27
+ end
data/bin/apn ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+
4
+ ARGV << '--help' if ARGV.empty?
5
+
6
+ require 'optparse'
7
+ require 'apn'
8
+ require 'daemons'
9
+
10
+ command = ARGV.shift
11
+
12
+ options = {}
13
+ OptionParser.new do |opts|
14
+ opts.banner = "Usage: apn [options]"
15
+
16
+ opts.on("--cert_file=MANDATORY", "Location of the cert pem file") do |cert_file|
17
+ options[:cert] = cert_file
18
+ end
19
+
20
+ opts.on("--cert_password=OPTIONAL", "Password for the cert pem file") do |cert_password|
21
+ options[:password] = cert_password
22
+ end
23
+
24
+ opts.on("--redis_host=OPTIONAL", "Redis hostname") do |host|
25
+ options[:redis_host] = host
26
+ end
27
+
28
+ opts.on("--redis_port=OPTIONAL", "Redis port") do |port|
29
+ options[:redis_port] = port
30
+ end
31
+
32
+ opts.on("--redis_password=OPTIONAL", "Redis password") do |redis_password|
33
+ options[:redis_password] = redis_password
34
+ end
35
+
36
+ opts.on("--environment=OPTIONAL", "Specify sandbox or production") do |env|
37
+ if env == 'production'
38
+ options[:host] = 'gateway.push.apple.com'
39
+ else
40
+ options[:host] = 'gateway.sandbox.push.apple.com'
41
+ end
42
+ end
43
+
44
+ opts.on("--queue=OPTIONAL", "Name of the redis queue") do |queue|
45
+ options[:queue] = queue
46
+ end
47
+
48
+ opts.on("--dir=OPTIONAL", "Directory to start in") do |dir|
49
+ options[:dir] = dir
50
+ end
51
+
52
+ opts.on("--log_file=OPTIONAL", "Logfile to log in") do |log_file|
53
+ options[:logfile] = log_file
54
+ end
55
+
56
+ opts.on('--help', 'Show help') do |help|
57
+ puts opts
58
+ end
59
+ end.parse!
60
+
61
+ options[:queue] ||= 'apn_queue'
62
+
63
+ pids_dir = "tmp/pids"
64
+ log_dir = "log"
65
+
66
+ case command
67
+ when 'start'
68
+ unless options[:foreground]
69
+ Daemons.daemonize(
70
+ :app_name => options[:queue],
71
+ :dir_mode => :normal,
72
+ :dir => pids_dir,
73
+ :log_dir => log_dir,
74
+ :backtrace => true,
75
+ :log_output => true
76
+ )
77
+ end
78
+
79
+ APN::Daemon.new(options).run!
80
+ when 'run'
81
+ APN::Daemon.new(options).run!
82
+ when 'stop'
83
+ unless options[:foreground]
84
+ files = Daemons::PidFile.find_files(pids_dir, options[:queue])
85
+ files.each do |file|
86
+ pid = File.open(file) {|h| h.read}.to_i
87
+ `kill -9 #{pid}`
88
+ end
89
+ end
90
+ end
data/lib/apn.rb ADDED
@@ -0,0 +1,51 @@
1
+ require 'apn/daemon'
2
+ require 'apn/notification'
3
+ require 'apn/config'
4
+ require 'apn/feedback'
5
+ require 'apn/client'
6
+ #require 'apn/log'
7
+ require 'apn/version'
8
+ require 'redis'
9
+
10
+ module APN
11
+ class << self
12
+ def queue(message, queue_name = 'apn_queue')
13
+ self.redis.lpush(queue_name, message.to_json)
14
+ end
15
+
16
+ def redis
17
+ @redis ||= Redis.new(:host => APN.config.redis_host, :port => APN.config.redis_port, :password => APN.config.redis_password)
18
+ end
19
+
20
+ def logger=(logger)
21
+ @logger = logger
22
+ end
23
+
24
+ def logger
25
+ @logger ||= Logger.new(logfile)
26
+ end
27
+
28
+ def log(level, message = nil)
29
+ level, message = 'info', level if message.nil? # Handle only one argument if called from Resque, which expects only message
30
+
31
+ return false unless logger && logger.respond_to?(level)
32
+ logger.send(level, "#{Time.now}: #{message}")
33
+ end
34
+
35
+ def log_and_die(msg)
36
+ logger.fatal(msg)
37
+ raise msg
38
+ end
39
+
40
+ def logfile
41
+ APN.config.logfile ? APN.config.logfile : STDOUT
42
+ end
43
+
44
+ def configure
45
+ block_given? ? yield(Config) : Config
46
+ end
47
+ alias :config :configure
48
+ end
49
+ end
50
+
51
+ require 'apn/railtie' if defined?(Rails)
data/lib/apn/client.rb ADDED
@@ -0,0 +1,85 @@
1
+ require 'openssl'
2
+ require 'socket'
3
+
4
+ module APN
5
+ class Client
6
+ def initialize(options = {})
7
+ @cert = options[:cert]
8
+ @password = options[:password]
9
+ @host = options[:host]
10
+ @port = options[:port]
11
+ end
12
+
13
+ def connect!
14
+ APN.log(:info, 'Connecting...')
15
+
16
+ cert = self.setup_certificate
17
+ @socket = self.setup_socket(cert)
18
+
19
+ APN.log(:info, 'Connected!')
20
+
21
+ @socket
22
+ end
23
+
24
+ def setup_certificate
25
+ APN.log(:info, 'Setting up certificate...')
26
+ @context = OpenSSL::SSL::SSLContext.new
27
+ @context.cert = OpenSSL::X509::Certificate.new(File.read(@cert))
28
+ @context.key = OpenSSL::PKey::RSA.new(File.read(@cert), @password)
29
+ APN.log(:info, 'Certificate created!')
30
+
31
+ @context
32
+ end
33
+
34
+ def setup_socket(ctx)
35
+ APN.log(:info, 'Connecting...')
36
+
37
+ socket_tcp = TCPSocket.new(@host, @port)
38
+ OpenSSL::SSL::SSLSocket.new(socket_tcp, ctx).tap do |s|
39
+ s.sync = true
40
+ s.connect
41
+ end
42
+ end
43
+
44
+ def reset_socket
45
+ @socket.close if @socket
46
+ @socket = nil
47
+
48
+ connect!
49
+ end
50
+
51
+ def socket
52
+ @socket ||= connect!
53
+ end
54
+
55
+ def push(notification)
56
+ begin
57
+ APN.log(:info, "Sending #{notification.device_token}: #{notification.json_payload}")
58
+ socket.write(notification.to_bytes)
59
+ socket.flush
60
+
61
+ if IO.select([socket], nil, nil, 1) && error = socket.read(6)
62
+ error = error.unpack('ccN')
63
+ APN.log(:error, "Encountered error in push method: #{error}, backtrace #{error.backtrace}")
64
+ return false
65
+ end
66
+
67
+ APN.log(:info, 'Message sent')
68
+
69
+ true
70
+ rescue OpenSSL::SSL::SSLError, Errno::EPIPE => e
71
+ APN.log(:error, "Encountered error: #{e}, backtrace #{e.backtrace}")
72
+ APN.log(:info, 'Trying to reconnect...')
73
+ reset_socket
74
+ APN.log(:info, 'Reconnected')
75
+ end
76
+ end
77
+
78
+ def feedback
79
+ if bunch = socket.read(38)
80
+ f = bunch.strip.unpack('N1n1H140')
81
+ APN::FeedbackItem.new(Time.at(f[0]), f[2])
82
+ end
83
+ end
84
+ end
85
+ end
data/lib/apn/config.rb ADDED
@@ -0,0 +1,38 @@
1
+ module APN
2
+ module Config
3
+ extend self
4
+
5
+ def option(name, options = {})
6
+ defaults[name] = settings[name] = options[:default]
7
+
8
+ class_eval <<-RUBY
9
+ def #{name}
10
+ settings[#{name.inspect}]
11
+ end
12
+
13
+ def #{name}=(value)
14
+ settings[#{name.inspect}] = value
15
+ end
16
+
17
+ def #{name}?
18
+ #{name}
19
+ end
20
+ RUBY
21
+ end
22
+
23
+ def defaults
24
+ @defaults ||= {}
25
+ end
26
+
27
+ def settings
28
+ @settings ||= {}
29
+ end
30
+
31
+ option :redis_host, :default => 'localhost'
32
+ option :redis_port, :default => 6379
33
+ option :redis_password
34
+ option :logfile
35
+ option :cert_file
36
+ option :cert_password
37
+ end
38
+ end
data/lib/apn/daemon.rb ADDED
@@ -0,0 +1,85 @@
1
+ require 'redis'
2
+ require 'logger'
3
+ require 'active_support/ordered_hash'
4
+ require 'active_support/json'
5
+ require 'base64'
6
+ require 'apn/client'
7
+ require 'apn/notification'
8
+ require 'apn/config'
9
+ #require 'apn/log'
10
+
11
+ module APN
12
+ class Daemon
13
+ attr_accessor :redis, :host, :apple, :cert, :queue, :connected, :logger, :airbrake
14
+
15
+ def initialize(options = {})
16
+ options[:redis_host] ||= 'localhost'
17
+ options[:redis_port] ||= '6379'
18
+ options[:host] ||= 'gateway.sandbox.push.apple.com'
19
+ options[:port] ||= 2195
20
+ options[:queue] ||= 'apn_queue'
21
+ options[:password] ||= ''
22
+ raise 'No cert provided!' unless options[:cert]
23
+
24
+ redis_options = { :host => options[:redis_host], :port => options[:redis_port] }
25
+ redis_options[:password] = options[:redis_password] if options.has_key?(:redis_password)
26
+
27
+ @redis = Redis.new(redis_options)
28
+ @queue = options[:queue]
29
+ @cert = options[:cert]
30
+ @password = options[:password]
31
+ @host = options[:host]
32
+ @port = options[:port]
33
+ @dir = options[:dir]
34
+
35
+ APN.configure do |config|
36
+ config.logfile = options[:logfile]
37
+ end
38
+
39
+ APN.log(:info, "Listening on queue: #{self.queue}")
40
+ end
41
+
42
+ def run!
43
+ @last_notification = nil
44
+
45
+ loop do
46
+ begin
47
+ message = @redis.blpop(self.queue, 1)
48
+ if message
49
+ @notification = APN::Notification.new(JSON.parse(message.last,:symbolize_names => true))
50
+
51
+ send_notification
52
+ end
53
+ rescue Exception => e
54
+ if e.class == Interrupt || e.class == SystemExit
55
+ APN.log(:info, 'Shutting down...')
56
+ exit(0)
57
+ end
58
+
59
+ APN.log(:error, "Encountered error: #{e}, backtrace #{e.backtrace}")
60
+
61
+ APN.log(:info, 'Trying to reconnect...')
62
+ client.connect!
63
+ APN.log(:info, 'Reconnected')
64
+
65
+ client.push(@notification)
66
+ end
67
+ end
68
+ end
69
+
70
+ def send_notification
71
+ if @last_notification.nil? || @last_notification < Time.now - 3600
72
+ APN.log(:info, 'Forced reconnection...')
73
+ client.connect!
74
+ end
75
+
76
+ client.push(@notification)
77
+
78
+ @last_notification = Time.now
79
+ end
80
+
81
+ def client
82
+ @client ||= APN::Client.new(host: @host, port: @port, cert: @cert, password: @password, dir: @dir, queue: @queue)
83
+ end
84
+ end
85
+ end