lowdown 0.0.1
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.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.ruby-version +1 -0
- data/.travis.yml +4 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +46 -0
- data/Rakefile +10 -0
- data/bin/lowdown +121 -0
- data/lib/lowdown.rb +5 -0
- data/lib/lowdown/certificate.rb +72 -0
- data/lib/lowdown/client.rb +73 -0
- data/lib/lowdown/connection.rb +164 -0
- data/lib/lowdown/notification.rb +22 -0
- data/lib/lowdown/response.rb +65 -0
- data/lib/lowdown/threading.rb +63 -0
- data/lib/lowdown/version.rb +3 -0
- data/lowdown.gemspec +29 -0
- metadata +118 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 596c18a9090845d35a4395a928c71c3b0c493f63
|
4
|
+
data.tar.gz: 2003c1226c6d4f1831452f1b83c5a2caeb0d38cc
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 11c94276f939853a735caae9b90a50ca288c6a9d77a070d5b5be8d5c7f401afefc0ce8a94ec249266bf10ad6d8cf92065fd91d3e8dfbd3e7295d3ffec741755a
|
7
|
+
data.tar.gz: 0652004863baf52a637f207243e1b2b891da389e6995565717378015efaf9eb12e40e83b7a73ff7e4a715c057e0e1599b4580f3e77b5e6d3b941434a27707180
|
data/.gitignore
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
ruby-2.2.4
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 Artsy, Eloy Durán <eloy.de.enige@gmail.com>
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# Lowdown
|
2
|
+
|
3
|
+
Lowdown is a Ruby client for the HTTP/2 version of the Apple Push Notification Service.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'lowdown'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
$ bundle install
|
16
|
+
|
17
|
+
Or install it yourself as:
|
18
|
+
|
19
|
+
$ gem install lowdown
|
20
|
+
|
21
|
+
## Usage
|
22
|
+
|
23
|
+
You can use the `lowdown` bin that comes with this gem or in code at it’s simplest:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
notification = Lowdown::Notification.new(:token => "device-token", :payload => { :alert => "Hello World!" })
|
27
|
+
|
28
|
+
Lowdown::Client.production(true, File.read("path/to/certificate.pem")).connect do |client|
|
29
|
+
client.send_notification(notification) do |response|
|
30
|
+
if response.success?
|
31
|
+
puts "Notification sent"
|
32
|
+
else
|
33
|
+
puts "Notification failed: #{response}"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
```
|
38
|
+
|
39
|
+
## Contributing
|
40
|
+
|
41
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/alloy/lowdown.
|
42
|
+
|
43
|
+
## License
|
44
|
+
|
45
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
46
|
+
|
data/Rakefile
ADDED
data/bin/lowdown
ADDED
@@ -0,0 +1,121 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "lowdown"
|
4
|
+
include Lowdown
|
5
|
+
|
6
|
+
require "json"
|
7
|
+
require "optparse"
|
8
|
+
|
9
|
+
options = { :payload => {}, :custom_data => {} }
|
10
|
+
|
11
|
+
OPTION_PARSER = OptionParser.new do |opts|
|
12
|
+
opts.banner = "Usage: lowdown [options] <tokens …>"
|
13
|
+
|
14
|
+
opts.on("-v", "--version", "Print version") do |v|
|
15
|
+
puts VERSION
|
16
|
+
exit
|
17
|
+
end
|
18
|
+
|
19
|
+
opts.on("-m", "--alert ALERT", "Body of the alert to send in the push notification") do |alert|
|
20
|
+
options[:alert] = alert
|
21
|
+
end
|
22
|
+
|
23
|
+
opts.on("-b", "--badge NUMBER", "Badge number to set with the push notification") do |badge|
|
24
|
+
options[:badge] = badge.to_i
|
25
|
+
end
|
26
|
+
|
27
|
+
opts.on("-s", "--sound SOUND", "Sound to play with the notification") do |sound|
|
28
|
+
options[:sound] = sound
|
29
|
+
end
|
30
|
+
|
31
|
+
opts.on("-x", "--[no-]content-available", "Indicates to the app that new content is available") do |available|
|
32
|
+
options[:content_available] = available
|
33
|
+
end
|
34
|
+
|
35
|
+
opts.on("-d", "--data KEY=VALUE", "Passes custom data to payload") do |custom_data|
|
36
|
+
key, value = custom_data.split("=", 2)
|
37
|
+
options[:custom_data][key] = value
|
38
|
+
end
|
39
|
+
|
40
|
+
opts.on("-P", "--payload PAYLOAD", "JSON payload for notifications, merged with --alert, --badge, --sound, and --data") do |payload|
|
41
|
+
options[:payload] = JSON.parse(payload)
|
42
|
+
end
|
43
|
+
|
44
|
+
opts.on("-t", "--topic TOPIC", "The topic for the notifications") do |topic|
|
45
|
+
options[:topic] = topic
|
46
|
+
end
|
47
|
+
|
48
|
+
opts.on("-e", "--environment ENV", "Environment to send push notification (production or development), defaults to certificate purpose or development") do |env|
|
49
|
+
options[:env] = env
|
50
|
+
end
|
51
|
+
|
52
|
+
opts.on("-c", "--certificate CERTIFICATE", "Path to certificate (.pem) file") do |file|
|
53
|
+
options[:certificate_file] = file
|
54
|
+
end
|
55
|
+
|
56
|
+
opts.on("-p", "--passphrase PASSPHRASE", "Certificate passphrase") do |passphrase|
|
57
|
+
options[:certificate_passphrase] = passphrase
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
OPTION_PARSER.parse!
|
62
|
+
tokens = ARGV
|
63
|
+
|
64
|
+
def help!(message)
|
65
|
+
puts message
|
66
|
+
puts
|
67
|
+
puts OPTION_PARSER
|
68
|
+
exit 1
|
69
|
+
end
|
70
|
+
|
71
|
+
certificate = nil
|
72
|
+
file, passphrase = options.values_at(:certificate_file, :certificate_passphrase)
|
73
|
+
unless file && File.exist?(file) && certificate = (Certificate.from_pem_data(File.read(file), passphrase) rescue nil)
|
74
|
+
help! "A valid certificate path is required."
|
75
|
+
end
|
76
|
+
|
77
|
+
production = false
|
78
|
+
if options[:env]
|
79
|
+
unless %w{ production development }.include?(options[:env])
|
80
|
+
help! "Invalid environment specified."
|
81
|
+
end
|
82
|
+
production = options[:env] == "production"
|
83
|
+
else
|
84
|
+
if certificate.development?
|
85
|
+
production = false
|
86
|
+
elsif certificate.production?
|
87
|
+
production = true
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
begin
|
92
|
+
client = Client.production(production, certificate)
|
93
|
+
rescue ArgumentError => e
|
94
|
+
help! e.message
|
95
|
+
end
|
96
|
+
|
97
|
+
payload = options[:payload]
|
98
|
+
payload.merge!(options[:custom_data])
|
99
|
+
payload["alert"] = options[:alert] if options[:alert]
|
100
|
+
payload["badge"] = options[:badge] if options[:badge]
|
101
|
+
payload["sound"] = options[:sound] if options[:sound]
|
102
|
+
payload["content-available"] = options[:content_available] ? 1 : 0 if options.has_key?(:content_available)
|
103
|
+
if payload.empty?
|
104
|
+
help! "No payload data specified."
|
105
|
+
end
|
106
|
+
|
107
|
+
if tokens.empty?
|
108
|
+
help! "No device tokens specified."
|
109
|
+
end
|
110
|
+
|
111
|
+
notifications = tokens.map.with_index do |token, index|
|
112
|
+
Notification.new(:token => token, :id => index+1, :payload => payload, :topic => options[:topic])
|
113
|
+
end
|
114
|
+
|
115
|
+
client.connect do
|
116
|
+
notifications.each do |notification|
|
117
|
+
client.send_notification(notification) do |response|
|
118
|
+
puts "[#{notification.token} ##{notification.id}] #{response}"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
data/lib/lowdown.rb
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
require "openssl"
|
2
|
+
|
3
|
+
module Lowdown
|
4
|
+
def self.Certificate(certificate_or_data)
|
5
|
+
if certificate_or_data.is_a?(Certificate)
|
6
|
+
certificate_or_data
|
7
|
+
else
|
8
|
+
Certificate.from_pem_data(certificate_or_data)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class Certificate
|
13
|
+
# http://images.apple.com/certificateauthority/pdf/Apple_WWDR_CPS_v1.13.pdf
|
14
|
+
DEVELOPMENT_ENV_EXTENSION = "1.2.840.113635.100.6.3.1".freeze
|
15
|
+
PRODUCTION_ENV_EXTENSION = "1.2.840.113635.100.6.3.2".freeze
|
16
|
+
UNIVERSAL_CERTIFICATE_EXTENSION = "1.2.840.113635.100.6.3.6".freeze
|
17
|
+
|
18
|
+
def self.from_pem_data(data, passphrase = nil)
|
19
|
+
key = OpenSSL::PKey::RSA.new(data, passphrase)
|
20
|
+
certificate = OpenSSL::X509::Certificate.new(data)
|
21
|
+
new(key, certificate)
|
22
|
+
end
|
23
|
+
|
24
|
+
attr_reader :key, :certificate
|
25
|
+
|
26
|
+
def initialize(key, certificate)
|
27
|
+
@key, @certificate = key, certificate
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_pem
|
31
|
+
"#{@key.to_pem}\n#{@certificate.to_pem}"
|
32
|
+
end
|
33
|
+
|
34
|
+
def ssl_context
|
35
|
+
@ssl_context ||= OpenSSL::SSL::SSLContext.new.tap do |context|
|
36
|
+
context.key = @key
|
37
|
+
context.cert = @certificate
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def universal?
|
42
|
+
!extension(UNIVERSAL_CERTIFICATE_EXTENSION).nil?
|
43
|
+
end
|
44
|
+
|
45
|
+
def development?
|
46
|
+
!extension(DEVELOPMENT_ENV_EXTENSION).nil?
|
47
|
+
end
|
48
|
+
|
49
|
+
def production?
|
50
|
+
!extension(PRODUCTION_ENV_EXTENSION).nil?
|
51
|
+
end
|
52
|
+
|
53
|
+
def topics
|
54
|
+
if universal?
|
55
|
+
components = extension(UNIVERSAL_CERTIFICATE_EXTENSION).value.split(/0?\.{2,}/)
|
56
|
+
components.select.with_index { |_, index| index.odd? }
|
57
|
+
else
|
58
|
+
[app_bundle_id]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def extension(oid)
|
65
|
+
@certificate.extensions.find { |ext| ext.oid == oid }
|
66
|
+
end
|
67
|
+
|
68
|
+
def app_bundle_id
|
69
|
+
@certificate.subject.to_a.find { |key, *_| key == 'UID' }[1]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require "lowdown/certificate"
|
2
|
+
require "lowdown/connection"
|
3
|
+
require "lowdown/notification"
|
4
|
+
|
5
|
+
require "uri"
|
6
|
+
require "json"
|
7
|
+
|
8
|
+
module Lowdown
|
9
|
+
class Client
|
10
|
+
DEVELOPMENT_URI = URI.parse("https://api.development.push.apple.com:443")
|
11
|
+
PRODUCTION_URI = URI.parse("https://api.push.apple.com:443")
|
12
|
+
|
13
|
+
def self.production(production, certificate_or_data)
|
14
|
+
certificate = Lowdown.Certificate(certificate_or_data)
|
15
|
+
if production
|
16
|
+
unless certificate.production?
|
17
|
+
raise ArgumentError, "The specified certificate is not usable with the production environment."
|
18
|
+
end
|
19
|
+
else
|
20
|
+
unless certificate.development?
|
21
|
+
raise ArgumentError, "The specified certificate is not usable with the development environment."
|
22
|
+
end
|
23
|
+
end
|
24
|
+
client(production ? PRODUCTION_URI : DEVELOPMENT_URI, certificate)
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.client(uri, certificate_or_data)
|
28
|
+
certificate = Lowdown.Certificate(certificate_or_data)
|
29
|
+
default_topic = certificate.topics.first if certificate.universal?
|
30
|
+
new(Connection.new(uri, certificate.ssl_context), default_topic)
|
31
|
+
end
|
32
|
+
|
33
|
+
attr_reader :connection, :default_topic
|
34
|
+
|
35
|
+
def initialize(connection, default_topic = nil)
|
36
|
+
@connection, @default_topic = connection, default_topic
|
37
|
+
end
|
38
|
+
|
39
|
+
def connect
|
40
|
+
@connection.open
|
41
|
+
if block_given?
|
42
|
+
begin
|
43
|
+
yield self
|
44
|
+
ensure
|
45
|
+
close
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def flush
|
51
|
+
@connection.flush
|
52
|
+
end
|
53
|
+
|
54
|
+
def close
|
55
|
+
@connection.close
|
56
|
+
end
|
57
|
+
|
58
|
+
def send_notification(notification, &callback)
|
59
|
+
raise ArgumentError, "Invalid notification: #{notification.inspect}" unless notification.valid?
|
60
|
+
|
61
|
+
topic = notification.topic || @default_topic
|
62
|
+
headers = {}
|
63
|
+
headers["apns-expiration"] = (notification.expiration || 0).to_i
|
64
|
+
headers["apns-id"] = notification.formatted_id if notification.id
|
65
|
+
headers["apns-priority"] = notification.priority if notification.priority
|
66
|
+
headers["apns-topic"] = topic if topic
|
67
|
+
|
68
|
+
body = { :aps => notification.payload }.to_json
|
69
|
+
|
70
|
+
@connection.post("/3/device/#{notification.token}", headers, body, &callback)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,164 @@
|
|
1
|
+
require "lowdown/threading"
|
2
|
+
require "lowdown/response"
|
3
|
+
|
4
|
+
require "http/2"
|
5
|
+
require "openssl"
|
6
|
+
require "uri"
|
7
|
+
require "socket"
|
8
|
+
|
9
|
+
# Monkey-patch http-2 gem until this PR is merged: https://github.com/igrigorik/http-2/pull/44
|
10
|
+
if HTTP2::VERSION == "0.8.0"
|
11
|
+
class HTTP2::Client
|
12
|
+
def connection_management(frame)
|
13
|
+
if @state == :waiting_connection_preface
|
14
|
+
send_connection_preface
|
15
|
+
connection_settings(frame)
|
16
|
+
else
|
17
|
+
super(frame)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
module Lowdown
|
24
|
+
class Connection
|
25
|
+
attr_reader :uri, :ssl_context
|
26
|
+
|
27
|
+
def initialize(uri, ssl_context)
|
28
|
+
@uri, @ssl_context = URI(uri), ssl_context
|
29
|
+
end
|
30
|
+
|
31
|
+
def open
|
32
|
+
@socket = TCPSocket.new(@uri.host, @uri.port)
|
33
|
+
|
34
|
+
@ssl = OpenSSL::SSL::SSLSocket.new(@socket, @ssl_context)
|
35
|
+
@ssl.sync_close = true
|
36
|
+
@ssl.hostname = @uri.hostname
|
37
|
+
@ssl.connect
|
38
|
+
|
39
|
+
@http = HTTP2::Client.new
|
40
|
+
@http.on(:frame) do |bytes|
|
41
|
+
@ssl.print(bytes)
|
42
|
+
@ssl.flush
|
43
|
+
end
|
44
|
+
|
45
|
+
@main_queue = Threading::DispatchQueue.new
|
46
|
+
@work_queue = Threading::DispatchQueue.new
|
47
|
+
@requests = Threading::Counter.new
|
48
|
+
@exceptions = Queue.new
|
49
|
+
@worker_thread = start_worker_thread!
|
50
|
+
end
|
51
|
+
|
52
|
+
def open?
|
53
|
+
!@ssl.nil? && !@ssl.closed?
|
54
|
+
end
|
55
|
+
|
56
|
+
# Terminates the worker thread and closes the socket. Finally it peforms one more check for pending jobs dispatched
|
57
|
+
# onto the main thread.
|
58
|
+
#
|
59
|
+
def close
|
60
|
+
flush
|
61
|
+
|
62
|
+
@worker_thread[:should_exit] = true
|
63
|
+
@worker_thread.join
|
64
|
+
|
65
|
+
@ssl.close
|
66
|
+
|
67
|
+
sleep 0.1
|
68
|
+
@main_queue.drain!
|
69
|
+
|
70
|
+
@socket = @ssl = @http = @main_queue = @work_queue = @requests = @exceptions = @worker_thread = nil
|
71
|
+
end
|
72
|
+
|
73
|
+
def flush
|
74
|
+
until @work_queue.empty? && @requests.zero?
|
75
|
+
@main_queue.drain!
|
76
|
+
sleep 0.1
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def post(path, headers, body, &callback)
|
81
|
+
request('POST', path, headers, body, &callback)
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def request(method, path, custom_headers, body, &callback)
|
87
|
+
@requests.increment!
|
88
|
+
@work_queue.dispatch do
|
89
|
+
headers = { ":method" => method.to_s, ":path" => path.to_s, "content-length" => body.bytesize.to_s }
|
90
|
+
custom_headers.each { |k, v| headers[k] = v.to_s }
|
91
|
+
|
92
|
+
stream = @http.new_stream
|
93
|
+
response = Response.new
|
94
|
+
|
95
|
+
stream.on(:headers) do |response_headers|
|
96
|
+
response.headers = Hash[*response_headers.flatten]
|
97
|
+
end
|
98
|
+
|
99
|
+
stream.on(:data) do |response_data|
|
100
|
+
response.raw_body ||= ''
|
101
|
+
response.raw_body << response_data
|
102
|
+
end
|
103
|
+
|
104
|
+
stream.on(:close) do
|
105
|
+
@main_queue.dispatch do
|
106
|
+
callback.call(response)
|
107
|
+
@requests.decrement!
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
stream.headers(headers, end_stream: false)
|
112
|
+
stream.data(body, end_stream: true)
|
113
|
+
end
|
114
|
+
|
115
|
+
# The caller might be posting many notifications, so use this time to also dispatch work onto the main thread.
|
116
|
+
@main_queue.drain!
|
117
|
+
end
|
118
|
+
|
119
|
+
def start_worker_thread!
|
120
|
+
Thread.new do
|
121
|
+
until Thread.current[:should_exit] || @ssl.closed?
|
122
|
+
# Run any dispatched jobs that add new requests.
|
123
|
+
#
|
124
|
+
# Re-raising a worker exception aids the development process. In production there’s no reason why this should
|
125
|
+
# raise at all.
|
126
|
+
if exception = @work_queue.drain!
|
127
|
+
exception_occurred_in_worker(exception)
|
128
|
+
end
|
129
|
+
|
130
|
+
# Try to read data from the SSL socket without blocking. If it would block, catch the exception and restart
|
131
|
+
# the loop.
|
132
|
+
begin
|
133
|
+
data = @ssl.read_nonblock(1024)
|
134
|
+
rescue IO::WaitReadable
|
135
|
+
data = nil
|
136
|
+
rescue EOFError => exception
|
137
|
+
exception_occurred_in_worker(exception)
|
138
|
+
Thread.current[:should_exit] = true
|
139
|
+
data = nil
|
140
|
+
end
|
141
|
+
|
142
|
+
# Process incoming HTTP data. If any processing exception occurs, fail the whole process.
|
143
|
+
if data
|
144
|
+
begin
|
145
|
+
@http << data
|
146
|
+
rescue Exception => exception
|
147
|
+
@ssl.close
|
148
|
+
exception_occurred_in_worker(exception)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# Raise the exception on the main thread and reset the number of in-flight requests so that a potential blocked
|
156
|
+
# caller of `Connection#flush` will continue.
|
157
|
+
#
|
158
|
+
def exception_occurred_in_worker(exception)
|
159
|
+
@exceptions << exception
|
160
|
+
@main_queue.dispatch { raise @exceptions.pop }
|
161
|
+
@requests.value = @http.active_stream_count
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Lowdown
|
2
|
+
# For payload documentation see: https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/TheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH107-SW1
|
3
|
+
#
|
4
|
+
class Notification
|
5
|
+
attr_accessor :token, :id, :expiration, :priority, :topic, :payload
|
6
|
+
|
7
|
+
def initialize(params)
|
8
|
+
params.each { |key, value| send("#{key}=", value) }
|
9
|
+
end
|
10
|
+
|
11
|
+
def valid?
|
12
|
+
!!(@token && @payload)
|
13
|
+
end
|
14
|
+
|
15
|
+
def formatted_id
|
16
|
+
if @id
|
17
|
+
padded = @id.to_s.rjust(32, "0")
|
18
|
+
[padded[0,8], padded[8,4], padded[12,4], padded[16,4], padded[20,12]].join("-")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module Lowdown
|
2
|
+
class Response < Struct.new(:headers, :raw_body)
|
3
|
+
# https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/APNsProviderAPI.html#//apple_ref/doc/uid/TP40008194-CH101-SW3
|
4
|
+
STATUS_CODES = {
|
5
|
+
200 => "Success",
|
6
|
+
400 => "Bad request",
|
7
|
+
403 => "There was an error with the certificate",
|
8
|
+
405 => "The request used a bad :method value. Only POST requests are supported",
|
9
|
+
410 => "The device token is no longer active for the topic",
|
10
|
+
413 => "The notification payload was too large",
|
11
|
+
429 => "The server received too many requests for the same device token",
|
12
|
+
500 => "Internal server error",
|
13
|
+
503 => "The server is shutting down and unavailable"
|
14
|
+
}
|
15
|
+
|
16
|
+
def id
|
17
|
+
headers["apns-id"]
|
18
|
+
end
|
19
|
+
|
20
|
+
def unformatted_id(length = nil)
|
21
|
+
id = self.id.tr('-', '')
|
22
|
+
length ? id[32-length,length] : id.gsub(/\A0*/, '')
|
23
|
+
end
|
24
|
+
|
25
|
+
def status
|
26
|
+
headers[":status"].to_i
|
27
|
+
end
|
28
|
+
|
29
|
+
def message
|
30
|
+
STATUS_CODES[status]
|
31
|
+
end
|
32
|
+
|
33
|
+
def success?
|
34
|
+
status == 200
|
35
|
+
end
|
36
|
+
|
37
|
+
def body
|
38
|
+
JSON.parse(raw_body) if raw_body
|
39
|
+
end
|
40
|
+
|
41
|
+
def failure_reason
|
42
|
+
body["reason"] unless success?
|
43
|
+
end
|
44
|
+
|
45
|
+
def invalid_token?
|
46
|
+
status == 410
|
47
|
+
end
|
48
|
+
|
49
|
+
# Only available when using an invalid token.
|
50
|
+
def validity_last_checked_at
|
51
|
+
Time.at(body["timestamp"].to_i) if invalid_token?
|
52
|
+
end
|
53
|
+
|
54
|
+
def to_s
|
55
|
+
s = "#{status} (#{message})"
|
56
|
+
s << ": #{failure_reason}" unless success?
|
57
|
+
s << " last checked at #{validity_last_checked_at}" if invalid_token?
|
58
|
+
s
|
59
|
+
end
|
60
|
+
|
61
|
+
def inspect
|
62
|
+
"#<Lowdown::Connection::Response #{to_s}>"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require "thread"
|
2
|
+
|
3
|
+
module Lowdown
|
4
|
+
module Threading
|
5
|
+
class DispatchQueue
|
6
|
+
def initialize
|
7
|
+
@queue = Queue.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def dispatch(&block)
|
11
|
+
@queue << block
|
12
|
+
end
|
13
|
+
|
14
|
+
def empty?
|
15
|
+
@queue.empty?
|
16
|
+
end
|
17
|
+
|
18
|
+
# Performs the number of dispatched blocks that were on the queue at the moment of calling #drain!. Unlike
|
19
|
+
# performing blocks _until the queue is empty_, this ensures that it doesn’t block the calling thread too long if
|
20
|
+
# another thread is dispatching more work at the same time.
|
21
|
+
#
|
22
|
+
# By default this will let any exceptions bubble up on the main thread or catch and return them on other threads.
|
23
|
+
#
|
24
|
+
def drain!(rescue_exceptions = (Thread.current != Thread.main))
|
25
|
+
@queue.size.times { @queue.pop.call }
|
26
|
+
nil
|
27
|
+
rescue Exception => exception
|
28
|
+
raise unless rescue_exceptions
|
29
|
+
exception
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class Counter
|
34
|
+
def initialize(value = 0)
|
35
|
+
@value = value
|
36
|
+
@mutex = Mutex.new
|
37
|
+
end
|
38
|
+
|
39
|
+
def value
|
40
|
+
value = nil
|
41
|
+
@mutex.synchronize { value = @value }
|
42
|
+
value
|
43
|
+
end
|
44
|
+
|
45
|
+
def zero?
|
46
|
+
value.zero?
|
47
|
+
end
|
48
|
+
|
49
|
+
def value=(value)
|
50
|
+
@mutex.synchronize { @value = value }
|
51
|
+
value
|
52
|
+
end
|
53
|
+
|
54
|
+
def increment!
|
55
|
+
@mutex.synchronize { @value += 1 }
|
56
|
+
end
|
57
|
+
|
58
|
+
def decrement!
|
59
|
+
@mutex.synchronize { @value -= 1 }
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
data/lowdown.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'lowdown/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "lowdown"
|
8
|
+
spec.version = Lowdown::VERSION
|
9
|
+
spec.authors = ["Eloy Durán"]
|
10
|
+
spec.email = ["eloy.de.enige@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = "A Ruby client for the HTTP/2 version of the Apple Push Notification Service."
|
13
|
+
spec.homepage = "https://github.com/alloy/lowdown"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| File.dirname(f) == "test" }
|
17
|
+
spec.bindir = "bin"
|
18
|
+
spec.executables = "lowdown"
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
# This is currently set to >= 2.0.0 in the http-2 gemspec, which is incorrect, as it uses required keyword arguments.
|
22
|
+
spec.required_ruby_version = '>= 2.1.0'
|
23
|
+
|
24
|
+
spec.add_runtime_dependency "http-2", ">= 0.8"
|
25
|
+
|
26
|
+
spec.add_development_dependency "bundler", "~> 1.11"
|
27
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
28
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
29
|
+
end
|
metadata
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: lowdown
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Eloy Durán
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-12-31 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: http-2
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.8'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.8'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.11'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.11'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: minitest
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '5.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '5.0'
|
69
|
+
description:
|
70
|
+
email:
|
71
|
+
- eloy.de.enige@gmail.com
|
72
|
+
executables:
|
73
|
+
- lowdown
|
74
|
+
extensions: []
|
75
|
+
extra_rdoc_files: []
|
76
|
+
files:
|
77
|
+
- ".gitignore"
|
78
|
+
- ".ruby-version"
|
79
|
+
- ".travis.yml"
|
80
|
+
- Gemfile
|
81
|
+
- LICENSE.txt
|
82
|
+
- README.md
|
83
|
+
- Rakefile
|
84
|
+
- bin/lowdown
|
85
|
+
- lib/lowdown.rb
|
86
|
+
- lib/lowdown/certificate.rb
|
87
|
+
- lib/lowdown/client.rb
|
88
|
+
- lib/lowdown/connection.rb
|
89
|
+
- lib/lowdown/notification.rb
|
90
|
+
- lib/lowdown/response.rb
|
91
|
+
- lib/lowdown/threading.rb
|
92
|
+
- lib/lowdown/version.rb
|
93
|
+
- lowdown.gemspec
|
94
|
+
homepage: https://github.com/alloy/lowdown
|
95
|
+
licenses:
|
96
|
+
- MIT
|
97
|
+
metadata: {}
|
98
|
+
post_install_message:
|
99
|
+
rdoc_options: []
|
100
|
+
require_paths:
|
101
|
+
- lib
|
102
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
103
|
+
requirements:
|
104
|
+
- - ">="
|
105
|
+
- !ruby/object:Gem::Version
|
106
|
+
version: 2.1.0
|
107
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - ">="
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: '0'
|
112
|
+
requirements: []
|
113
|
+
rubyforge_project:
|
114
|
+
rubygems_version: 2.4.5.1
|
115
|
+
signing_key:
|
116
|
+
specification_version: 4
|
117
|
+
summary: A Ruby client for the HTTP/2 version of the Apple Push Notification Service.
|
118
|
+
test_files: []
|