├── Gemfile ├── .gitignore ├── lib ├── houston │ ├── version.rb │ ├── connection.rb │ ├── client.rb │ └── notification.rb └── houston.rb ├── Rakefile ├── spec ├── spec_helper.rb ├── client_spec.rb └── notification_spec.rb ├── LICENSE ├── houston.gemspec ├── bin └── apn └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pem 2 | *.cer 3 | Gemfile.lock 4 | coverage/ 5 | -------------------------------------------------------------------------------- /lib/houston/version.rb: -------------------------------------------------------------------------------- 1 | module Houston 2 | VERSION = "2.2.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/houston.rb: -------------------------------------------------------------------------------- 1 | require 'houston/version' 2 | require 'houston/client' 3 | require 'houston/notification' 4 | require 'houston/connection' 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | Bundler.setup 3 | 4 | gemspec = eval(File.read("houston.gemspec")) 5 | 6 | task :build => "#{gemspec.full_name}.gem" 7 | 8 | file "#{gemspec.full_name}.gem" => gemspec.files + ["houston.gemspec"] do 9 | system "gem build houston.gemspec" 10 | end 11 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | unless ENV['CI'] 2 | require 'simplecov' 3 | 4 | SimpleCov.start do 5 | add_filter '/spec/' 6 | end 7 | end 8 | 9 | require 'houston' 10 | require 'rspec' 11 | 12 | class MockConnection 13 | class << self 14 | def open(uri, certificate, passphrase) 15 | yield self.new 16 | end 17 | end 18 | 19 | def initialize 20 | @unregistered_devices = [ 21 | [443779200, 32, "ce8be6272e43e85516033e24b4c289220eeda4879c477160b2545e95b68b5969"], 22 | [1388678223, 32, "ce8be6272e43e85516033e24b4c289220eeda4879c477160b2545e95b68b5970"] 23 | ] 24 | end 25 | 26 | def read(bytes) 27 | return nil if @unregistered_devices.empty? 28 | 29 | @unregistered_devices.shift.pack('N1n1H*') 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Mattt Thompson (http://mattt.me/) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /houston.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "houston/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "houston" 7 | s.authors = ["Mattt Thompson"] 8 | s.email = "m@mattt.me" 9 | s.license = "MIT" 10 | s.homepage = "http://nomad-cli.com" 11 | s.version = Houston::VERSION 12 | s.platform = Gem::Platform::RUBY 13 | s.summary = "Send Apple Push Notifications" 14 | s.description = "Houston is a simple gem for sending Apple Push Notifications. Pass your credentials, construct your message, and send it." 15 | 16 | s.add_dependency "commander", "~> 4.1" 17 | s.add_dependency "json" 18 | 19 | s.add_development_dependency "rspec", "~> 3.0" 20 | s.add_development_dependency "rake" 21 | s.add_development_dependency "simplecov" 22 | 23 | s.files = Dir["./**/*"].reject { |file| file =~ /\.\/(bin|log|pkg|script|spec|test|vendor)/ } 24 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 25 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 26 | s.require_paths = ["lib"] 27 | end 28 | -------------------------------------------------------------------------------- /lib/houston/connection.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | require 'socket' 3 | require 'openssl' 4 | require 'forwardable' 5 | 6 | module Houston 7 | class Connection 8 | extend Forwardable 9 | def_delegators :@ssl, :read, :write 10 | def_delegators :@uri, :scheme, :host, :port 11 | 12 | attr_reader :ssl, :socket, :certificate, :passphrase 13 | 14 | class << self 15 | def open(uri, certificate, passphrase) 16 | return unless block_given? 17 | 18 | connection = new(uri, certificate, passphrase) 19 | connection.open 20 | 21 | yield connection 22 | 23 | connection.close 24 | end 25 | end 26 | 27 | def initialize(uri, certificate, passphrase) 28 | @uri = URI(uri) 29 | @certificate = certificate 30 | @passphrase = passphrase 31 | end 32 | 33 | def open 34 | return false if open? 35 | 36 | @socket = TCPSocket.new(@uri.host, @uri.port) 37 | 38 | context = OpenSSL::SSL::SSLContext.new 39 | context.key = OpenSSL::PKey::RSA.new(@certificate, @passphrase) 40 | context.cert = OpenSSL::X509::Certificate.new(@certificate) 41 | 42 | @ssl = OpenSSL::SSL::SSLSocket.new(@socket, context) 43 | @ssl.sync = true 44 | @ssl.connect 45 | end 46 | 47 | def open? 48 | not (@ssl and @socket).nil? 49 | end 50 | 51 | def close 52 | return false if closed? 53 | 54 | @ssl.close 55 | @ssl = nil 56 | 57 | @socket.close 58 | @socket = nil 59 | end 60 | 61 | def closed? 62 | not open? 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/houston/client.rb: -------------------------------------------------------------------------------- 1 | module Houston 2 | APPLE_PRODUCTION_GATEWAY_URI = "apn://gateway.push.apple.com:2195" 3 | APPLE_PRODUCTION_FEEDBACK_URI = "apn://feedback.push.apple.com:2196" 4 | 5 | APPLE_DEVELOPMENT_GATEWAY_URI = "apn://gateway.sandbox.push.apple.com:2195" 6 | APPLE_DEVELOPMENT_FEEDBACK_URI = "apn://feedback.sandbox.push.apple.com:2196" 7 | 8 | class Client 9 | attr_accessor :gateway_uri, :feedback_uri, :certificate, :passphrase, :timeout 10 | 11 | class << self 12 | def development 13 | client = self.new 14 | client.gateway_uri = APPLE_DEVELOPMENT_GATEWAY_URI 15 | client.feedback_uri = APPLE_DEVELOPMENT_FEEDBACK_URI 16 | client 17 | end 18 | 19 | def production 20 | client = self.new 21 | client.gateway_uri = APPLE_PRODUCTION_GATEWAY_URI 22 | client.feedback_uri = APPLE_PRODUCTION_FEEDBACK_URI 23 | client 24 | end 25 | end 26 | 27 | def initialize 28 | @gateway_uri = ENV['APN_GATEWAY_URI'] 29 | @feedback_uri = ENV['APN_FEEDBACK_URI'] 30 | @certificate = ENV['APN_CERTIFICATE'] 31 | @passphrase = ENV['APN_CERTIFICATE_PASSPHRASE'] 32 | @timeout = Float(ENV['APN_TIMEOUT'] || 0.5) 33 | end 34 | 35 | def push(*notifications) 36 | return if notifications.empty? 37 | 38 | notifications.flatten! 39 | 40 | Connection.open(@gateway_uri, @certificate, @passphrase) do |connection| 41 | ssl = connection.ssl 42 | 43 | notifications.each_with_index do |notification, index| 44 | next unless notification.kind_of?(Notification) 45 | next if notification.sent? 46 | next unless notification.valid? 47 | 48 | notification.id = index 49 | 50 | connection.write(notification.message) 51 | notification.mark_as_sent! 52 | 53 | read_socket, write_socket = IO.select([ssl], [ssl], [ssl], nil) 54 | if (read_socket && read_socket[0]) 55 | if error = connection.read(6) 56 | command, status, index = error.unpack("ccN") 57 | notification.apns_error_code = status 58 | notification.mark_as_unsent! 59 | end 60 | end 61 | end 62 | end 63 | end 64 | 65 | def unregistered_devices 66 | devices = [] 67 | 68 | Connection.open(@feedback_uri, @certificate, @passphrase) do |connection| 69 | while line = connection.read(38) 70 | feedback = line.unpack('N1n1H140') 71 | timestamp = feedback[0] 72 | token = feedback[2].scan(/.{0,8}/).join(' ').strip 73 | devices << {:token => token, :timestamp => timestamp} if token && timestamp 74 | end 75 | end 76 | 77 | devices 78 | end 79 | 80 | def devices 81 | unregistered_devices.collect{|device| device[:token]} 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Houston::Client do 4 | subject { Houston::Client.development } 5 | 6 | before(:each) do 7 | stub_const("Houston::Connection", MockConnection) 8 | end 9 | 10 | context '#development' do 11 | subject { Houston::Client.development } 12 | 13 | describe '#gateway_uri' do 14 | subject { super().gateway_uri } 15 | it { should == Houston::APPLE_DEVELOPMENT_GATEWAY_URI} 16 | end 17 | 18 | describe '#feedback_uri' do 19 | subject { super().feedback_uri } 20 | it { should == Houston::APPLE_DEVELOPMENT_FEEDBACK_URI} 21 | end 22 | end 23 | 24 | context '#production' do 25 | subject { Houston::Client.production } 26 | 27 | describe '#gateway_uri' do 28 | subject { super().gateway_uri } 29 | it { should == Houston::APPLE_PRODUCTION_GATEWAY_URI} 30 | end 31 | 32 | describe '#feedback_uri' do 33 | subject { super().feedback_uri } 34 | it { should == Houston::APPLE_PRODUCTION_FEEDBACK_URI} 35 | end 36 | end 37 | 38 | context '#new' do 39 | context 'passing options through ENV' do 40 | ENV['APN_GATEWAY_URI'] = "apn://gateway.example.com" 41 | ENV['APN_FEEDBACK_URI'] = "apn://feedback.example.com" 42 | ENV['APN_CERTIFICATE'] = "path/to/certificate" 43 | ENV['APN_CERTIFICATE_PASSPHRASE'] = "passphrase" 44 | ENV['APN_TIMEOUT'] = "10.0" 45 | 46 | subject do 47 | Houston::Client.new 48 | end 49 | 50 | describe '#gateway_uri' do 51 | subject { super().gateway_uri } 52 | it { should == ENV['APN_GATEWAY_URI'] } 53 | end 54 | 55 | describe '#feedback_uri' do 56 | subject { super().feedback_uri } 57 | it { should == ENV['APN_FEEDBACK_URI'] } 58 | end 59 | 60 | describe '#certificate' do 61 | subject { super().certificate } 62 | it { should == ENV['APN_CERTIFICATE'] } 63 | end 64 | 65 | describe '#passphrase' do 66 | subject { super().passphrase } 67 | it { should == ENV['APN_CERTIFICATE_PASSPHRASE'] } 68 | end 69 | 70 | describe '#timeout' do 71 | subject { super().timeout } 72 | it { should be_a(Float) } 73 | it { should == Float(ENV['APN_TIMEOUT']) } 74 | end 75 | end 76 | 77 | describe '#push' do 78 | it 'should accept zero arguments' do 79 | expect(Houston::Client.development.push()).to be_nil() 80 | end 81 | end 82 | end 83 | 84 | describe '#unregistered_devices' do 85 | it 'should correctly parse the feedback response and create a dictionary of unregistered devices with timestamps' do 86 | expect(subject.unregistered_devices).to eq [ 87 | {:token=>"ce8be627 2e43e855 16033e24 b4c28922 0eeda487 9c477160 b2545e95 b68b5969", :timestamp=>443779200}, 88 | {:token=>"ce8be627 2e43e855 16033e24 b4c28922 0eeda487 9c477160 b2545e95 b68b5970", :timestamp=>1388678223} 89 | ] 90 | end 91 | end 92 | 93 | describe '#devices' do 94 | it 'should correctly parse the feedback response and create an array of unregistered devices' do 95 | expect(subject.devices).to eq [ 96 | "ce8be627 2e43e855 16033e24 b4c28922 0eeda487 9c477160 b2545e95 b68b5969", 97 | "ce8be627 2e43e855 16033e24 b4c28922 0eeda487 9c477160 b2545e95 b68b5970" 98 | ] 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/houston/notification.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Houston 4 | class Notification 5 | class APNSError < RuntimeError 6 | # See: https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW12 7 | CODES = { 8 | 0 => "No errors encountered", 9 | 1 => "Processing error", 10 | 2 => "Missing device token", 11 | 3 => "Missing topic", 12 | 4 => "Missing payload", 13 | 5 => "Invalid token size", 14 | 6 => "Invalid topic size", 15 | 7 => "Invalid payload size", 16 | 8 => "Invalid token", 17 | 10 => "Shutdown", 18 | 255 => "Unknown error" 19 | } 20 | 21 | attr_reader :code 22 | 23 | def initialize(code) 24 | raise ArgumentError unless CODES.include?(code) 25 | super(CODES[code]) 26 | @code = code 27 | end 28 | end 29 | 30 | MAXIMUM_PAYLOAD_SIZE = 2048 31 | 32 | attr_accessor :token, :alert, :badge, :sound, :category, :content_available, :custom_data, :id, :expiry, :priority 33 | attr_reader :sent_at 34 | attr_writer :apns_error_code 35 | 36 | alias :device :token 37 | alias :device= :token= 38 | 39 | def initialize(options = {}) 40 | @token = options.delete(:token) || options.delete(:device) 41 | @alert = options.delete(:alert) 42 | @badge = options.delete(:badge) 43 | @sound = options.delete(:sound) 44 | @category = options.delete(:category) 45 | @expiry = options.delete(:expiry) 46 | @id = options.delete(:id) 47 | @priority = options.delete(:priority) 48 | @content_available = options.delete(:content_available) 49 | 50 | @custom_data = options 51 | end 52 | 53 | def payload 54 | json = {}.merge(@custom_data || {}).inject({}){|h,(k,v)| h[k.to_s] = v; h} 55 | 56 | json['aps'] ||= {} 57 | json['aps']['alert'] = @alert if @alert 58 | json['aps']['badge'] = @badge.to_i rescue 0 if @badge 59 | json['aps']['sound'] = @sound if @sound 60 | json['aps']['category'] = @category if @category 61 | json['aps']['content-available'] = 1 if @content_available 62 | 63 | json 64 | end 65 | 66 | def message 67 | data = [device_token_item, 68 | payload_item, 69 | identifier_item, 70 | expiration_item, 71 | priority_item].compact.join 72 | [2, data.bytes.count, data].pack('cNa*') 73 | end 74 | 75 | def mark_as_sent! 76 | @sent_at = Time.now 77 | end 78 | 79 | def mark_as_unsent! 80 | @sent_at = nil 81 | end 82 | 83 | def sent? 84 | !!@sent_at 85 | end 86 | 87 | def valid? 88 | payload.to_json.bytesize <= MAXIMUM_PAYLOAD_SIZE 89 | end 90 | 91 | def error 92 | APNSError.new(@apns_error_code) if @apns_error_code and @apns_error_code.nonzero? 93 | end 94 | 95 | private 96 | 97 | def device_token_item 98 | [1, 32, @token.gsub(/[<\s>]/, '')].pack('cnH64') 99 | end 100 | 101 | def payload_item 102 | json = JSON::dump(payload) 103 | [2, json.bytes.count, json].pack('cna*') 104 | end 105 | 106 | def identifier_item 107 | [3, 4, @id].pack('cnN') unless @id.nil? 108 | end 109 | 110 | def expiration_item 111 | [4, 4, @expiry.to_i].pack('cnN') unless @expiry.nil? 112 | end 113 | 114 | def priority_item 115 | [5, 1, @priority].pack('cnc') unless @priority.nil? 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /bin/apn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'commander/import' 4 | 5 | require 'houston' 6 | 7 | HighLine.track_eof = false # Fix for built-in Ruby 8 | Signal.trap("INT") {} # Suppress backtrace when exiting command 9 | 10 | program :version, Houston::VERSION 11 | program :description, 'A command-line interface for sending push notifications' 12 | 13 | program :help, 'Author', 'Mattt Thompson ' 14 | program :help, 'Website', 'https://github.com/mattt' 15 | program :help_formatter, :compact 16 | 17 | default_command :help 18 | 19 | command :push do |c| 20 | c.syntax = 'apn push TOKEN [...]' 21 | c.summary = 'Sends an Apple Push Notification to specified devices' 22 | c.description = '' 23 | 24 | c.example 'description', 'apn push -m "Hello, World" -b 57 -s sosumi.aiff' 25 | c.option '-m', '--alert ALERT', 'Body of the alert to send in the push notification' 26 | c.option '-b', '--badge NUMBER', 'Badge number to set with the push notification' 27 | c.option '-s', '--sound SOUND', 'Sound to play with the notification' 28 | c.option '-y', '--category CATEGORY', 'Category of notification' 29 | c.option '-n', '--[no]-newsstand', 'Indicates content available for Newsstand' 30 | c.option '-d', '--data KEY=VALUE', Array, 'Passes custom data to payload (as comma-delimited "key=value" declarations)' 31 | c.option '-P', '--payload PAYLOAD', 'JSON payload for notifications' 32 | c.option '-e', '--environment ENV', [:production, :development], 'Environment to send push notification (production or development (default))' 33 | c.option '-c', '--certificate CERTIFICATE', 'Path to certificate (.pem) file' 34 | c.option '-p', '--[no]-passphrase', 'Prompt for a certificate passphrase' 35 | 36 | c.action do |args, options| 37 | say_error "One or more device tokens required" and abort if args.empty? 38 | 39 | @environment = options.environment.downcase.to_sym rescue :development 40 | say_error "Invalid environment,'#{@environment}' (should be either :development or :production)" and abort unless [:development, :production].include?(@environment) 41 | 42 | @certificate = options.certificate 43 | say_error "Missing certificate file option (-c /path/to/certificate.pem)" and abort unless @certificate 44 | say_error "Could not find certificate file '#{@certificate}'" and abort unless File.exists?(@certificate) 45 | 46 | @passphrase = options.passphrase ? password : "" 47 | 48 | @alert = options.alert 49 | @badge = options.badge.nil? ? nil : options.badge.to_i 50 | @sound = options.sound 51 | @category = options.category 52 | @content_available = !!options.newsstand 53 | 54 | if options.payload 55 | begin 56 | @data = JSON.parse(options.payload) 57 | rescue => message 58 | say_error "Exception parsing JSON payload: #{message}" and abort 59 | end 60 | elsif options.data 61 | begin 62 | @data = Hash[options.data.collect{|data| data.split(/\=/)}] 63 | rescue => message 64 | say_error "Exception parsing JSON payload: #{message}" and abort 65 | end 66 | end 67 | 68 | unless @alert or @badge or @content_available or @data 69 | placeholder = "Enter your alert message" 70 | @alert = ask_editor placeholder 71 | say_error "Payload contents required" and abort if @alert.nil? or @alert == placeholder 72 | end 73 | 74 | @notifications = [] 75 | args.each do |token| 76 | notification = Houston::Notification.new(@data || {}) 77 | notification.token = token 78 | notification.alert = @alert if @alert 79 | notification.badge = @badge if @badge 80 | notification.sound = @sound if @sound 81 | notification.category = @category if @category 82 | notification.content_available = @content_available if @content_available 83 | 84 | @notifications << notification 85 | end 86 | 87 | client = (@environment == :production) ? Houston::Client.production : Houston::Client.development 88 | client.certificate = File.read(@certificate) 89 | client.passphrase = @passphrase 90 | 91 | begin 92 | client.push(*@notifications) 93 | 94 | sent, unsent = @notifications.partition{|notification| notification.sent?} 95 | 96 | if sent.any? 97 | case sent.length 98 | when 1 99 | say_ok "1 push notification sent successfully" 100 | else 101 | say_ok "#{sent.length} push notifications sent successfully" 102 | end 103 | end 104 | 105 | if unsent.any? 106 | tokens = unsent.map{|notification| "\n - #{notification.token}"}.join 107 | 108 | case unsent.length 109 | when 1 110 | say_error "1 push notification unsuccessful (#{tokens})" 111 | else 112 | say_error "#{unsent.length} push notifications unsuccessful (#{tokens})" 113 | end 114 | end 115 | 116 | rescue => message 117 | say_error "Exception sending notification: #{message}" and abort 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Houston](https://raw.github.com/nomad/nomad.github.io/assets/houston-banner.png) 2 | 3 | Push Notifications don't have to be difficult. 4 | 5 | Houston is a simple gem for sending Apple Push Notifications. Pass your credentials, construct your message, and send it. 6 | 7 | In a production application, you will probably want to schedule or queue notifications into a background job. Whether you're using [queue_classic](https://github.com/ryandotsmith/queue_classic), [resque](https://github.com/defunkt/resque), or rolling you own infrastructure, integrating Houston couldn't be simpler. 8 | 9 | Another caveat is that Houston doesn't manage device tokens for you. For that, you should check out [Helios](http://helios.io) 10 | 11 | > Houston is named for [Houston, TX](http://en.wikipedia.org/wiki/Houston), the metonymical home of [NASA's Johnson Space Center](http://en.wikipedia.org/wiki/Lyndon_B._Johnson_Space_Center), as in _Houston, We Have Liftoff!_. 12 | 13 | > It's part of a series of world-class command-line utilities for iOS development, which includes [Cupertino](https://github.com/mattt/cupertino) (Apple Dev Center management), [Shenzhen](https://github.com/mattt/shenzhen) (Building & Distribution), [Venice](https://github.com/mattt/venice) (In-App Purchase Receipt Verification), and [Dubai](https://github.com/mattt/dubai) (Passbook pass generation). 14 | 15 | > This project is also part of a series of open source libraries covering the mission-critical aspects of an iOS app's infrastructure. Be sure to check out its sister projects: [GroundControl](https://github.com/mattt/GroundControl), [SkyLab](https://github.com/mattt/SkyLab), [houston](https://github.com/mattt/houston), and [Orbiter](https://github.com/mattt/Orbiter). 16 | 17 | ## Installation 18 | 19 | $ gem install houston 20 | 21 | ## Usage 22 | 23 | ```ruby 24 | require 'houston' 25 | 26 | # Environment variables are automatically read, or can be overridden by any specified options. You can also 27 | # conveniently use `Houston::Client.development` or `Houston::Client.production`. 28 | APN = Houston::Client.development 29 | APN.certificate = File.read("/path/to/apple_push_notification.pem") 30 | 31 | # An example of the token sent back when a device registers for notifications 32 | token = "" 33 | 34 | # Create a notification that alerts a message to the user, plays a sound, and sets the badge on the app 35 | notification = Houston::Notification.new(device: token) 36 | notification.alert = "Hello, World!" 37 | 38 | # Notifications can also change the badge count, have a custom sound, have a category identifier, indicate available Newsstand content, or pass along arbitrary data. 39 | notification.badge = 57 40 | notification.sound = "sosumi.aiff" 41 | notification.category = "INVITE_CATEGORY" 42 | notification.content_available = true 43 | notification.custom_data = {foo: "bar"} 44 | 45 | # And... sent! That's all it takes. 46 | APN.push(notification) 47 | ``` 48 | 49 | ### Error Handling 50 | 51 | If an error occurs when sending a particular notification, its `error` attribute will be populated: 52 | 53 | ```ruby 54 | puts "Error: #{notification.error}." if notification.error 55 | ``` 56 | 57 | ### Silent Notifications 58 | 59 | To send a silent push notification, set `sound` to an empty string (`''`): 60 | 61 | ```ruby 62 | Houston::Notification.new(:sound => '', 63 | :content_available => true) 64 | ``` 65 | 66 | ### Persistent Connections 67 | 68 | If you want to manage your own persistent connection to Apple push services, such as for background workers, here's how to do it: 69 | 70 | ```ruby 71 | certificate = File.read("/path/to/apple_push_notification.pem") 72 | passphrase = "..." 73 | connection = Houston::Connection.new(Houston::APPLE_DEVELOPMENT_GATEWAY_URI, certificate, passphrase) 74 | connection.open 75 | 76 | notification = Houston::Notification.new(device: token) 77 | notification.alert = "Hello, World!" 78 | connection.write(notification.message) 79 | 80 | connection.close 81 | ``` 82 | 83 | ### Feedback Service 84 | 85 | Apple provides a feedback service to query for unregistered device tokens, these are devices that have failed to receive a push notification and should be removed from your application. You should periodically query for and remove these devices, Apple audits providers to ensure they are removing unregistered devices. To obtain the list of unregistered device tokens: 86 | 87 | ```ruby 88 | Houston::Client.development.devices 89 | ``` 90 | 91 | ## Versioning 92 | 93 | Houston 2.0 supports the new [enhanced notification format](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html#//apple_ref/doc/uid/TP40008194-CH101-SW4). Support for the legacy notification format is available in 1.x releases. 94 | 95 | ## Command Line Tool 96 | 97 | Houston also comes with the `apn` binary, which provides a convenient way to test notifications from the command line. 98 | 99 | $ apn push "" -c /path/to/apple_push_notification.pem -m "Hello from the command line!" 100 | 101 | ## Enabling Push Notifications on iOS 102 | 103 | ### AppDelegate.m 104 | 105 | ```objective-c 106 | - (void)applicationDidFinishLaunching:(UIApplication *)application { 107 | // ... 108 | 109 | [[UIApplication sharedApplication] registerForRemoteNotificationTypes:(UIRemoteNotificationTypeAlert | UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound)]; 110 | } 111 | 112 | - (void)application:(UIApplication *)application 113 | didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken 114 | { 115 | NSLog(@"application:didRegisterForRemoteNotificationsWithDeviceToken: %@", deviceToken); 116 | 117 | // Register the device token with a webservice 118 | } 119 | 120 | - (void)application:(UIApplication *)application 121 | didFailToRegisterForRemoteNotificationsWithError:(NSError *)error 122 | { 123 | NSLog(@"Error: %@", error); 124 | } 125 | ``` 126 | 127 | ## Converting Your Certificate 128 | 129 | > These instructions come from the [APN on Rails](https://github.com/PRX/apn_on_rails) project, which is another great option for sending push notifications. 130 | 131 | Once you have the certificate from Apple for your application, export your key 132 | and the apple certificate as p12 files. Here is a quick walkthrough on how to do this: 133 | 134 | 1. Click the disclosure arrow next to your certificate in Keychain Access and select the certificate and the key. 135 | 2. Right click and choose `Export 2 items…`. 136 | 3. Choose the p12 format from the drop down and name it `cert.p12`. 137 | 138 | Now covert the p12 file to a pem file: 139 | 140 | $ openssl pkcs12 -in cert.p12 -out apple_push_notification.pem -nodes -clcerts 141 | 142 | ## Contact 143 | 144 | Mattt Thompson 145 | 146 | - http://github.com/mattt 147 | - http://twitter.com/mattt 148 | - m@mattt.me 149 | 150 | ## License 151 | 152 | Houston is available under the MIT license. See the LICENSE file for more info. 153 | -------------------------------------------------------------------------------- /spec/notification_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Houston::Notification do 4 | let(:notification_options) { 5 | { 6 | token: '', 7 | alert: 'Houston, we have a problem.', 8 | badge: 2701, 9 | sound: 'sosumi.aiff', 10 | expiry: 1234567890, 11 | id: 42, 12 | priority: 10, 13 | content_available: true, 14 | key1: 1, 15 | key2: 'abc' 16 | } 17 | } 18 | 19 | subject { Houston::Notification.new(notification_options) } 20 | 21 | describe '#token' do 22 | subject { super().token } 23 | it { should == '' } 24 | end 25 | 26 | describe '#alert' do 27 | subject { super().alert } 28 | it { should == 'Houston, we have a problem.' } 29 | end 30 | 31 | describe '#badge' do 32 | subject { super().badge } 33 | it { should == 2701 } 34 | end 35 | 36 | describe '#sound' do 37 | subject { super().sound } 38 | it { should == 'sosumi.aiff' } 39 | end 40 | 41 | describe '#expiry' do 42 | subject { super().expiry } 43 | it { should == 1234567890 } 44 | end 45 | 46 | describe '#id' do 47 | subject { super().id } 48 | it { should == 42 } 49 | end 50 | 51 | describe '#priority' do 52 | subject { super().priority } 53 | it { should == 10 } 54 | end 55 | 56 | describe '#content_available' do 57 | subject { super().content_available } 58 | it { should be_truthy } 59 | end 60 | 61 | describe '#custom_data' do 62 | subject { super().custom_data } 63 | it { should == { key1: 1, key2: 'abc' } } 64 | end 65 | 66 | context 'using :device instead of :token' do 67 | subject do 68 | notification_options[:device] = notification_options[:token] 69 | notification_options.delete(:token) 70 | Houston::Notification.new(notification_options) 71 | end 72 | 73 | describe '#device' do 74 | subject { super().device } 75 | it { should == '' } 76 | end 77 | end 78 | 79 | describe '#payload' do 80 | it 'should create a compliant dictionary' do 81 | expect(subject.payload).to eq({ 82 | 'aps' => { 83 | 'alert' => 'Houston, we have a problem.', 84 | 'badge' => 2701, 85 | 'sound' => 'sosumi.aiff', 86 | 'content-available' => 1 87 | }, 88 | 'key1' => 1, 89 | 'key2' => 'abc' 90 | }) 91 | end 92 | 93 | it 'should create a dictionary of only custom data and empty aps' do 94 | expect(Houston::Notification.new(key1: 123, key2: 'xyz').payload).to eq({ 95 | 'aps' => {}, 96 | 'key1' => 123, 97 | 'key2' => 'xyz' 98 | }) 99 | end 100 | 101 | it 'should create a dictionary only with alerts' do 102 | expect(Houston::Notification.new(alert: 'Hello, World!').payload).to eq({ 103 | 'aps' => { 'alert' => 'Hello, World!' } 104 | }) 105 | end 106 | 107 | it 'should create a dictionary only with badges' do 108 | expect(Houston::Notification.new(badge: '123').payload).to eq({ 109 | 'aps' => { 'badge' => 123 } 110 | }) 111 | end 112 | 113 | it 'should create a dictionary only with sound' do 114 | expect(Houston::Notification.new(sound: 'ring.aiff').payload).to eq({ 115 | 'aps' => { 'sound' => 'ring.aiff' } 116 | }) 117 | end 118 | 119 | it 'should create a dictionary only with content-available' do 120 | expect(Houston::Notification.new(content_available: true).payload).to eq({ 121 | 'aps' => { 'content-available' => 1 } 122 | }) 123 | end 124 | 125 | it 'should allow custom data inside aps key' do 126 | notification_options = { :badge => 567, 'aps' => { 'loc-key' => 'my-key' } } 127 | expect(Houston::Notification.new(notification_options).payload).to eq({ 128 | 'aps' => { 'loc-key' => 'my-key', 'badge' => 567 } 129 | }) 130 | end 131 | 132 | it 'should create notification from hash with string and symbol keys' do 133 | notification_options = { :badge => 567, :aps => { 'loc-key' => 'my-key' } } 134 | expect(Houston::Notification.new(notification_options).payload['aps']).to eq({ 135 | 'loc-key' => 'my-key', 'badge' => 567 136 | }) 137 | end 138 | end 139 | 140 | describe '#sent?' do 141 | it 'should be false initially' do 142 | expect(subject.sent?).to be_falsey 143 | end 144 | 145 | it 'should be true after marking as sent' do 146 | subject.mark_as_sent! 147 | expect(subject.sent?).to be_truthy 148 | end 149 | 150 | it 'should be false after marking as unsent' do 151 | subject.mark_as_sent! 152 | subject.mark_as_unsent! 153 | expect(subject.sent?).to be_falsey 154 | end 155 | end 156 | 157 | describe '#message' do 158 | it 'should create a message with command 2' do 159 | command, _1, _2 = subject.message.unpack('cNa*') 160 | expect(command).to eq(2) 161 | end 162 | 163 | it 'should create a message with correct frame length' do 164 | _1, length, _2 = subject.message.unpack('cNa*') 165 | expect(length).to eq(182) 166 | end 167 | 168 | def parse_items(items_stream) 169 | items = [] 170 | until items_stream.empty? 171 | item_id, item_length, items_stream = items_stream.unpack('cna*') 172 | item_data, items_stream = items_stream.unpack("a#{item_length}a*") 173 | items << [item_id, item_length, item_data] 174 | end 175 | items 176 | end 177 | 178 | it 'should include five items' do 179 | _1, _2, items_stream = subject.message.unpack('cNa*') 180 | expect(parse_items(items_stream).size).to eq(5) 181 | end 182 | 183 | it 'should include an item #1 with the token as hexadecimal' do 184 | _1, _2, items_stream = subject.message.unpack('cNa*') 185 | items = parse_items(items_stream) 186 | expect(items).to include([1, 32, ['ce8be6272e43e85516033e24b4c289220eeda4879c477160b2545e95b68b5969'].pack('H*')]) 187 | end 188 | 189 | it 'should include an item #2 with the payload as JSON' do 190 | _1, _2, items_stream = subject.message.unpack('cNa*') 191 | items = parse_items(items_stream) 192 | expect(items).to include([2, 126, '{"key1":1,"key2":"abc","aps":{"alert":"Houston, we have a problem.","badge":2701,"sound":"sosumi.aiff","content-available":1}}']) 193 | end 194 | 195 | it 'should include an item #3 with the identifier' do 196 | _1, _2, items_stream = subject.message.unpack('cNa*') 197 | items = parse_items(items_stream) 198 | expect(items).to include([3, 4, [42].pack('N')]) 199 | end 200 | 201 | it 'should include an item #4 with the expiry' do 202 | _1, _2, items_stream = subject.message.unpack('cNa*') 203 | items = parse_items(items_stream) 204 | expect(items).to include([4, 4, [1234567890].pack('N')]) 205 | end 206 | 207 | it 'should include an item #5 with the priority' do 208 | _1, _2, items_stream = subject.message.unpack('cNa*') 209 | items = parse_items(items_stream) 210 | expect(items).to include([5, 1, [10].pack('c')]) 211 | end 212 | 213 | it 'should pad or truncate token so it is 32 bytes long' do 214 | notification_options[:token] = '' 215 | _1, _2, items_stream = subject.message.unpack('cNa*') 216 | items = parse_items(items_stream) 217 | expect(items).to include([1, 32, ['ce8be6272e43e85516033e24b4c2892200000000000000000000000000000000'].pack('H*')]) 218 | end 219 | 220 | it 'might be missing the identifier item' do 221 | notification_options.delete(:id) 222 | notification = Houston::Notification.new(notification_options) 223 | msg = notification.message 224 | _1, _2, items_stream = notification.message.unpack('cNa*') 225 | items = parse_items(items_stream) 226 | expect(items.size).to eq(4) 227 | expect(items.find { |item| item[0] == 3 }).to be_nil 228 | end 229 | 230 | it 'might be missing the expiry item' do 231 | notification_options.delete(:expiry) 232 | notification = Houston::Notification.new(notification_options) 233 | msg = notification.message 234 | _1, _2, items_stream = notification.message.unpack('cNa*') 235 | items = parse_items(items_stream) 236 | expect(items.size).to eq(4) 237 | expect(items.find { |item| item[0] == 4 }).to be_nil 238 | end 239 | 240 | it 'might be missing the priority item' do 241 | notification_options.delete(:priority) 242 | notification = Houston::Notification.new(notification_options) 243 | msg = notification.message 244 | _1, _2, items_stream = notification.message.unpack('cNa*') 245 | items = parse_items(items_stream) 246 | expect(items.size).to eq(4) 247 | expect(items.find { |item| item[0] == 5 }).to be_nil 248 | end 249 | end 250 | 251 | describe '#error' do 252 | context 'a status code has been set' do 253 | it 'returns an error object mapped to that status code' do 254 | status_code = 1 255 | notification = Houston::Notification.new(notification_options) 256 | notification.apns_error_code = status_code 257 | expect(notification.error.message).to eq(Houston::Notification::APNSError::CODES[status_code]) 258 | end 259 | end 260 | 261 | context 'a status code has been set to 0' do 262 | it 'returns nil' do 263 | status_code = 0 264 | notification = Houston::Notification.new(notification_options) 265 | notification.apns_error_code = status_code 266 | expect(notification.error).to be_nil 267 | end 268 | end 269 | 270 | context 'a status code has not been set' do 271 | it 'returns nil' do 272 | notification = Houston::Notification.new(notification_options) 273 | expect(notification.error).to be_nil 274 | end 275 | end 276 | end 277 | end 278 | --------------------------------------------------------------------------------