apn 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +17 -0
- data/.idea/.name +1 -0
- data/.idea/.rakeTasks +7 -0
- data/.idea/apn.iml +29 -0
- data/.idea/encodings.xml +5 -0
- data/.idea/misc.xml +5 -0
- data/.idea/modules.xml +9 -0
- data/.idea/scopes/scope_settings.xml +5 -0
- data/.idea/vcs.xml +7 -0
- data/.idea/workspace.xml +630 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +74 -0
- data/Rakefile +1 -0
- data/apn.gemspec +27 -0
- data/bin/apn +90 -0
- data/lib/apn.rb +51 -0
- data/lib/apn/client.rb +85 -0
- data/lib/apn/config.rb +38 -0
- data/lib/apn/daemon.rb +85 -0
- data/lib/apn/feedback.rb +46 -0
- data/lib/apn/log.rb +20 -0
- data/lib/apn/notification.rb +34 -0
- data/lib/apn/railtie.rb +13 -0
- data/lib/apn/version.rb +3 -0
- metadata +153 -0
data/Gemfile
ADDED
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
|