push_kit-apns 1.0.0.pre.beta1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 81a37cc16b4ed2fa5d99091530b2352cfce87b8329b7573c2a57cd66c8f951c6
4
+ data.tar.gz: d7d6b80348f17dec42e2ba44cc1c2b8e9348a4cd5979b63e4194d0aa728f7d5a
5
+ SHA512:
6
+ metadata.gz: c3bf44f4cd9d0935ed1ed3ea76f5ae5d7407ec9723946f8086e262a5184f8e92f62ba69268585c706c66bf21e5a3fab6529858e9af2c57f03139a859a0772747
7
+ data.tar.gz: 39b9bf33159a6a53669515700f169d9865c8442aa8d05d625c34032bfc9b46b9c14571cc2fd3bd190992c5eade594dbd029fc9c7f638aa75c0429c5fd039a37e
@@ -0,0 +1,46 @@
1
+ # Packages #
2
+ ############
3
+ *.7z
4
+ *.dmg
5
+ *.gz
6
+ *.iso
7
+ *.jar
8
+ *.rar
9
+ *.tar
10
+ *.zip
11
+
12
+ # Logs #
13
+ ########
14
+ *.log
15
+
16
+ # Databases #
17
+ #############
18
+ *.sql
19
+ *.sqlite
20
+
21
+ # OS Files #
22
+ ############
23
+ .DS_Store
24
+ .Trashes
25
+ ehthumbs.db
26
+ Icon?
27
+ Thumbs.db
28
+
29
+ # Vagrant #
30
+ ###########
31
+ .vagrant
32
+
33
+ # Ruby Files #
34
+ ##############
35
+ /.bundle/
36
+ /.yardoc
37
+ /Gemfile.lock
38
+ /_yardoc/
39
+ /coverage/
40
+ /doc/
41
+ /pkg/
42
+ /spec/reports/
43
+ /spec/examples.txt
44
+ /tmp/
45
+ /vendor/bundle/
46
+ *.gem
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
@@ -0,0 +1,21 @@
1
+ AllCops:
2
+ Exclude:
3
+ - 'bin/**/*'
4
+
5
+ Metrics/AbcSize:
6
+ Max: 30
7
+
8
+ Metrics/LineLength:
9
+ Max: 120
10
+
11
+ Metrics/BlockLength:
12
+ Enabled: false
13
+
14
+ Metrics/ClassLength:
15
+ Enabled: false
16
+
17
+ Metrics/MethodLength:
18
+ Enabled: false
19
+
20
+ Style/Documentation:
21
+ Enabled: false
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ guard :rspec, cmd: 'bundle exec rspec' do
4
+ require 'guard/rspec/dsl'
5
+
6
+ dsl = Guard::RSpec::Dsl.new(self)
7
+
8
+ # RSpec files
9
+ rspec = dsl.rspec
10
+ watch(rspec.spec_helper) { rspec.spec_dir }
11
+ watch(rspec.spec_support) { rspec.spec_dir }
12
+ watch(rspec.spec_files)
13
+
14
+ # Ruby files
15
+ ruby = dsl.ruby
16
+ dsl.watch_spec_files_for(ruby.lib_files)
17
+ end
@@ -0,0 +1,118 @@
1
+ # PushKit
2
+
3
+ PushKit makes it easy to create and deliver push notifications.
4
+
5
+ ## PushKit::APNS
6
+
7
+ PushKit::APNS provides an easy-to-use API for creating and then delivering notifications via the Apple Push Notification service.
8
+ It uses the new HTTP/2 API and tokens so no more yearly certificate renewals!
9
+
10
+ ## Installation
11
+
12
+ You can install **PushKit::APNS** using the following command:
13
+
14
+ $ gem install push_kit-apns
15
+
16
+ ### Usage
17
+
18
+ You'll need to grab a few things in order to send push notifications:
19
+
20
+ - A key from the Apple Developer portal, one which has the push notification option enabled.
21
+ - The ID of the key you're going to use.
22
+ - The ID of the team that generated the key.
23
+ - The bundle identifier of your application, which is used as the APNS topic.
24
+
25
+ Once you've obtained all of the above, you can create a new client like this:
26
+
27
+ ```ruby
28
+ client = PushKit::APNS.client(
29
+ key: PushKit::APNS.load_key('/path/to/APNsAuthKey_XXXXXXXXXX.p8'),
30
+ key_id: 'XXXXXXXXXX',
31
+ team_id: 'XXXXXXXXXX',
32
+ topic: 'com.example.app'
33
+ )
34
+ ```
35
+
36
+ Ideally, you should create the client when your app boots and keep it until your app is rebooted.
37
+ This allows us to maintain a connection to the Apple Push Notification service and quickly deliver notifications without the overhead of establishing a connection.
38
+
39
+ See [Communicating with APNs - Best Practices for Managing Connections](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW8) for more info on this.
40
+
41
+ ### Additional Options
42
+
43
+ There are a few additional options you can specify when creating a client.
44
+
45
+ ##### :host
46
+
47
+ You can provide the full hostname (or IP address) as a String. For example: 'api.push.apple.com'
48
+
49
+ Because this option's value is rarely set to anything other than the standard hostnames, we've added
50
+ a couple of convenience Symbols. These are *:development* and *:production* which sets the hostname
51
+ to the appropriate hostname for that environment.
52
+
53
+ The default value of this option is *:development*.
54
+
55
+ ##### :port
56
+
57
+ You can also provide the port number as an Integer. For example: 443
58
+ This also has a couple of convenience Symbols, *:default* and *:alternative*.
59
+
60
+ The default value of this option is *:default*.
61
+
62
+ ---
63
+
64
+ Creating a notification is easy with PushKit and there are quite a few different options you can set.
65
+ For a full list, checkout the documentation for `PushKit::APNS::Notification`.
66
+
67
+ For now, let's just create a simple notification:
68
+
69
+ ```ruby
70
+ notification = PushKit::APNS::Notification.new
71
+ notification.title = 'Hello, World!'
72
+ notification.body = 'How are you today?'
73
+ notification.sound = :default
74
+ notification.device_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
75
+ ```
76
+
77
+ Then using our `PushKit::APNS` instance we created earlier, deliver the notification:
78
+
79
+ ```ruby
80
+ apns.deliver(notification)
81
+ ```
82
+
83
+ EXPLAIN_ASYNC_NEEDS_WAIT
84
+
85
+ ---
86
+
87
+ The `PushKit::APNS::Notification` instance has a *:device_token* attribute which needs to be set to the token of the device you're sending the notification to.
88
+
89
+ It's common to want to send the same notification to a bunch of devices. When you want to do this, leave the initial notification's *:device_token* attribute blank and instead use the helper method *:for_tokens*:
90
+
91
+ ```ruby
92
+ notification = PushKit::APNS::Notification.new
93
+ notification.title = 'Hello, World!'
94
+ notification.body = 'How are you today?'
95
+ notification.sound = :default
96
+
97
+ tokens = %w[
98
+ 1111111111111111111111111111111111111111111111111111111111111111
99
+ 2222222222222222222222222222222222222222222222222222222222222222
100
+ 3333333333333333333333333333333333333333333333333333333333333333
101
+ ]
102
+
103
+ notifications = notification.for_tokens(*tokens)
104
+ ```
105
+
106
+ When you're ready, you can just deliver the array of notifications:
107
+
108
+ ```ruby
109
+ client.deliver(notifications) do |notification, result|
110
+ # ...
111
+ end
112
+ ```
113
+
114
+ ## Development
115
+
116
+ After checking out the repo, run `bundle exec rake spec` to run the tests.
117
+
118
+ To install this gem onto your machine, run `bundle exec rake install`.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'push_kit/apns'
5
+ require 'irb'
6
+
7
+ IRB.start
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'http/2'
5
+ require 'json'
6
+ require 'jwt'
7
+ require 'openssl'
8
+ require 'securerandom'
9
+ require 'uri'
10
+
11
+ require 'push_kit/apns/constants'
12
+ require 'push_kit/apns/notification/localization'
13
+ require 'push_kit/apns/notification'
14
+ require 'push_kit/apns/http_client'
15
+ require 'push_kit/apns/token_generator'
16
+ require 'push_kit/apns/push_client'
17
+
18
+ module PushKit
19
+ # PushKit::APNS provides an easy-to-use API for creating and then delivering notifications via the
20
+ # Apple Push Notification service.
21
+ #
22
+ module APNS
23
+ # Read the key from the file at the specified path.
24
+ #
25
+ # @param path [String] Path to a key file.
26
+ # @return [OpenSSL::PKey::EC] The loaded key.
27
+ #
28
+ def self.load_key(path)
29
+ raise ArgumentError, "The key file does not exist: #{path}" unless File.file?(path)
30
+
31
+ OpenSSL::PKey::EC.new(File.read(path))
32
+ end
33
+
34
+ # Create a new PushClient instance.
35
+ #
36
+ # @param options [Hash] The APNS options:
37
+ # host [String|Symbol] The host (can also be :production or :development).
38
+ # port [Integer|Symbol] The port number (can also be :default or :alternative).
39
+ # topic [String] The APNS topic (matches the app's bundle identifier).
40
+ # key [OpenSSL::PKey::EC] The elliptic curve key to use for authentication.
41
+ # key_id [String] The identifier for the elliptic curve key.
42
+ # team_id [String] The identifier for the Apple Developer team.
43
+ # topic [String] The topic for your application (usually the bundle identifier).
44
+ #
45
+ def self.client(options = {})
46
+ options = {
47
+ host: :development,
48
+ port: :default
49
+ }.merge(options)
50
+
51
+ token_generator = TokenGenerator.new(
52
+ key: options[:key],
53
+ key_id: options[:key_id],
54
+ team_id: options[:team_id]
55
+ )
56
+
57
+ PushClient.new(
58
+ host: options[:host],
59
+ port: options[:port],
60
+ topic: options[:topic],
61
+ token_generator: token_generator
62
+ )
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushKit
4
+ module APNS
5
+ # @return [String] The gem's semantic version number.
6
+ #
7
+ VERSION = '1.0.0-beta1'
8
+ end
9
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushKit
4
+ module APNS
5
+ # The HTTPClient class provides a minimal HTTP/2 client.
6
+ #
7
+ class HTTPClient
8
+ # @return [String] The ALPN protocol required by HTTP/2.
9
+ #
10
+ ALPN_PROTOCOL = 'h2'
11
+
12
+ # @return [URI] The URI which indicates the scheme, host and port of the remote server.
13
+ #
14
+ attr_reader :uri
15
+
16
+ # Creates a new HTTPClient.
17
+ #
18
+ # @param uri [String|URI] A URI which indicates the scheme, host and port of the remote server.
19
+ #
20
+ def initialize(uri)
21
+ case uri
22
+ when String then @uri = URI(uri)
23
+ when URI then @uri = uri
24
+ else raise 'Expected the :uri attribute to be a String or a URI.'
25
+ end
26
+ end
27
+
28
+ # Perform a HTTP request.
29
+ #
30
+ # @param method [String|Symbol] The request method (:get, :post, etc).
31
+ # @param path [String] The request path.
32
+ # @param headers [Hash] The request headers.
33
+ # @param body [String] The request body.
34
+ # @param block [Proc] A block to call once the request has completed.
35
+ #
36
+ # rubocop:disable Metrics/CyclomaticComplexity
37
+ # rubocop:disable Metrics/PerceivedComplexity
38
+ #
39
+ def request(method: nil, path: nil, headers: nil, body: nil, &block)
40
+ raise 'The :method should be a String or a Symbol' unless method.is_a?(String) || method.is_a?(Symbol)
41
+ raise 'The :path should be a String' unless path.is_a?(String)
42
+ raise 'The :headers should be a Hash' unless headers.nil? || headers.is_a?(Hash)
43
+ raise 'The :body should be a String' unless body.nil? || body.is_a?(String)
44
+ raise 'The completion block must be provided.' unless block.is_a?(Proc)
45
+
46
+ requests.push(method: method, path: path, headers: headers, body: body, completion: block)
47
+
48
+ perform_requests
49
+
50
+ nil
51
+ end
52
+ # rubocop:enable Metrics/CyclomaticComplexity
53
+ # rubocop:enable Metrics/PerceivedComplexity
54
+
55
+ private
56
+
57
+ # @return [Mutex] The semaphore used to ensure only one #perform_requests method call executes at a time.
58
+ #
59
+ def perform_requests_mutex
60
+ @perform_requests_mutex ||= Mutex.new
61
+ end
62
+
63
+ # @return [Queue] The queue containing requests that need to be processed.
64
+ #
65
+ def requests
66
+ @requests ||= Queue.new
67
+ end
68
+
69
+ # @return [OpenSSL::SSL::SSLSocket|TCPSocket] An SSL or TCP socket.
70
+ #
71
+ def socket
72
+ ssl_socket || tcp_socket
73
+ end
74
+
75
+ # Reset the client and sockets if the connection is broken.
76
+ #
77
+ # rubocop:disable Metrics/CyclomaticComplexity
78
+ # rubocop:disable Metrics/PerceivedComplexity
79
+ #
80
+ def reset_if_required!
81
+ socket = @ssl_socket || @tcp_socket
82
+
83
+ return unless socket.nil? || socket.closed? || @client.nil? || @client.closed?
84
+
85
+ @ssl_socket.close unless @ssl_socket.nil? || @ssl_socket.closed?
86
+ @tcp_socket.close unless @tcp_socket.nil? || @tcp_socket.closed?
87
+
88
+ @ssl_socket = nil
89
+ @tcp_socket = nil
90
+ @client = nil
91
+ end
92
+ # rubocop:enable Metrics/CyclomaticComplexity
93
+ # rubocop:enable Metrics/PerceivedComplexity
94
+
95
+ # @return [HTTP2::Client] An HTTP/2 client.
96
+ #
97
+ def client
98
+ reset_if_required!
99
+
100
+ return @client unless @client.nil? || @client.closed?
101
+
102
+ @client = HTTP2::Client.new
103
+
104
+ @client.on(:frame) do |bytes|
105
+ next if socket.closed?
106
+
107
+ socket.print(bytes)
108
+ socket.flush
109
+ end
110
+
111
+ @client.send_connection_preface
112
+
113
+ # The @client instance variable must exist before calling this method!
114
+ spawn_input_thread
115
+
116
+ @client
117
+ end
118
+
119
+ # @return [TCPSocket] The TCP socket.
120
+ #
121
+ def tcp_socket
122
+ return @tcp_socket unless @tcp_socket.nil? || @tcp_socket.closed?
123
+
124
+ @tcp_socket = TCPSocket.new(uri.host, uri.port)
125
+ end
126
+
127
+ # @return [OpenSSL::SSL::SSLSocket] An SSL socket.
128
+ #
129
+ def ssl_socket
130
+ return @ssl_socket unless @ssl_socket.nil? || @ssl_socket.closed?
131
+
132
+ # Only create the SSLSocket if the URI requires a secure connection.
133
+ return nil unless uri.scheme == 'https'
134
+
135
+ socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
136
+ socket.sync_close = true
137
+ socket.hostname = uri.host
138
+ socket.connect
139
+
140
+ if socket.alpn_protocol != ALPN_PROTOCOL
141
+ raise "Expected ALPN protocol '#{ALPN_PROTOCOL}' but received '#{socket.alpn_protocol}'."
142
+ end
143
+
144
+ @ssl_socket = socket
145
+ end
146
+
147
+ # @return [OpenSSL::SSL::SSLContext] An SSL context.
148
+ #
149
+ def ssl_context
150
+ context = OpenSSL::SSL::SSLContext.new
151
+ context.verify_mode = OpenSSL::SSL::VERIFY_NONE
152
+ context.alpn_protocols = [ALPN_PROTOCOL]
153
+ context.alpn_select_cb = lambda do |protocols|
154
+ ALPN_PROTOCOL if protocols.include?(ALPN_PROTOCOL)
155
+ end
156
+
157
+ context
158
+ end
159
+
160
+ # Loop over the requests in the queue, performing them until we've emptied the queue or we've hit
161
+ # the concurrent stream limit.
162
+ #
163
+ def perform_requests
164
+ return unless perform_requests_mutex.try_lock
165
+
166
+ loop do
167
+ begin
168
+ request = requests.pop(true)
169
+ perform_request(request)
170
+ rescue HTTP2::Error::StreamLimitExceeded
171
+ requests.push(request)
172
+ break
173
+ rescue ThreadError
174
+ break
175
+ end
176
+ end
177
+
178
+ perform_requests_mutex.unlock
179
+ end
180
+
181
+ # @param request [Hash] The request.
182
+ #
183
+ def perform_request(request)
184
+ stream = client.new_stream
185
+
186
+ if (block = request[:completion])
187
+ handle_response(stream, &block)
188
+ end
189
+
190
+ headers = headers(request)
191
+ body = request[:body]
192
+
193
+ if body.is_a?(String)
194
+ stream.headers(headers, end_stream: false)
195
+ stream.data(body, end_stream: true)
196
+ else
197
+ stream.headers(headers, end_stream: true)
198
+ end
199
+ end
200
+
201
+ # @param stream [HTTP2::Stream] The unique stream for the request.
202
+ # @param block [Proc] A block to call once the request has completed.
203
+ #
204
+ def handle_response(stream, &block)
205
+ return unless block.is_a?(Proc)
206
+
207
+ response_headers = {}
208
+ response_body = String.new
209
+
210
+ stream.on(:headers) do |headers|
211
+ response_headers.merge!(Hash[headers])
212
+ end
213
+
214
+ stream.on(:data) do |data|
215
+ response_body.concat(data)
216
+ end
217
+
218
+ stream.on(:close) do
219
+ perform_requests
220
+
221
+ status = response_headers[':status']
222
+ status = status.to_i if status.is_a?(String)
223
+ status = nil unless status.is_a?(Integer)
224
+
225
+ block.call(status, response_headers, response_body)
226
+ end
227
+ end
228
+
229
+ # @param request [Hash] The request.
230
+ # @return [Hash] The request headers.
231
+ #
232
+ def headers(request)
233
+ return nil unless request.is_a?(Hash)
234
+ return nil unless (method = request[:method])
235
+ return nil unless (path = request[:path])
236
+
237
+ headers = {
238
+ ':scheme' => uri.scheme,
239
+ ':authority' => [uri.host, uri.port].join(':'),
240
+ ':method' => method.to_s.upcase,
241
+ ':path' => path.to_s
242
+ }
243
+
244
+ body = request[:body]
245
+ headers['content-length'] = body.length.to_s if body.is_a?(String)
246
+
247
+ headers.merge!(request[:headers]) if request[:headers].is_a?(Hash)
248
+
249
+ headers
250
+ end
251
+
252
+ # Spawn a thread to wrap the #input_loop method.
253
+ #
254
+ def spawn_input_thread
255
+ return if @input_thread&.alive?
256
+
257
+ @input_thread = Thread.new { input_loop }
258
+ end
259
+
260
+ # Start a continuous loop, reading bytes from the socket and passing them to the HTTP/2 client.
261
+ #
262
+ def input_loop
263
+ loop do
264
+ break if @client.nil? || @client.closed? || socket.closed? || socket.eof?
265
+
266
+ begin
267
+ @client << socket.read_nonblock(1024)
268
+ rescue StandardError
269
+ socket.close
270
+
271
+ raise
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end