Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## [0.8.0]

- Support the iOS 27 poster styles: `poster_pass_type`, `poster_primary_fields`,
`footer_fields` and `featured_actions`. A pass that sets `poster_pass_type` emits
both style dictionaries, so older devices keep rendering the classic layout.
- Fix `boarding_pass` being dropped: its keys were merged into a discarded copy of
the `boardingPass` dictionary and never reached `pass.json`.

## [0.7.0]
- [#25](https://github.com/coorasse/passkit/pull/25): Change the label default color to black.

Expand Down
4 changes: 4 additions & 0 deletions app/models/passkit/pass.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ class Pass < ActiveRecord::Base
:boarding_pass,
:description,
:expiration_date,
:featured_actions,
:file_name,
:footer_fields,
:foreground_color,
:format_version,
:grouping_identifier,
Expand All @@ -34,6 +36,8 @@ class Pass < ActiveRecord::Base
:pass_path,
:pass_type,
:pass_type_identifier,
:poster_pass_type,
:poster_primary_fields,
:primary_fields,
:relevant_date,
:secondary_fields,
Expand Down
28 changes: 28 additions & 0 deletions lib/passkit/base_pass.rb
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,34 @@ def sharing_prohibited
false
end

# Poster style dictionary to emit alongside pass_type. :posterGeneric (iOS 27+)
# is the only poster style Wallet accepts as a top-level key -- posterEventTicket
# is a preferredStyleSchemes entry instead, not a style dictionary.
# Older iOS ignores the unknown key and renders pass_type instead, so a pass
# carrying both is readable everywhere.
def poster_pass_type
end

# Poster styles replace the secondary/auxiliary rows of the classic layouts
# with a single footer field.
def footer_fields
[]
end

# The row above the footer of a poster style. The classic primary fields sit
# above the barcode instead, so a pass carrying both layouts usually needs
# different content here.
def poster_primary_fields
primary_fields
end

# Up to two buttons rendered on the face of the pass (iOS 27+).
# Returns an array of hashes representing Pass.FeaturedActions
# i.e {identifier: "redeem", type: "shop", url: "https://example.com"}
def featured_actions
[]
end

private

def folder_name
Expand Down
24 changes: 22 additions & 2 deletions lib/passkit/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ def clean_ds_store_files
end

def generate_json_pass(pass_type_identifier)
File.write(@temporary_path.join("pass.json"), pass_json(pass_type_identifier).to_json)
end

def pass_json(pass_type_identifier)
pass = {
formatVersion: @pass.format_version,
teamIdentifier: @pass.apple_team_identifier,
Expand Down Expand Up @@ -105,6 +109,9 @@ def generate_json_pass(pass_type_identifier)
pass[:userInfo] = @pass.user_info if @pass.user_info
pass[:sharing] = @pass.sharing if @pass.sharing && !@pass[:sharing_prohibited]

featured_actions = @pass.featured_actions
pass[:featuredActions] = featured_actions if featured_actions.present?

pass[@pass.pass_type] = {
headerFields: @pass.header_fields,
primaryFields: @pass.primary_fields,
Expand All @@ -113,9 +120,22 @@ def generate_json_pass(pass_type_identifier)
backFields: @pass.back_fields
}

pass[:boardingPass].merge(@pass.boarding_pass) if @pass.pass_type == :boardingPass && @pass.boarding_pass
# Devices that know the poster style render this dictionary; the rest ignore
# the unknown key and fall back to the one above.
if @pass.poster_pass_type
pass[@pass.poster_pass_type] = {
headerFields: @pass.header_fields,
primaryFields: @pass.poster_primary_fields,
footerFields: @pass.footer_fields,
backFields: @pass.back_fields
}
end

if @pass.pass_type == :boardingPass && @pass.boarding_pass
pass[:boardingPass] = pass[:boardingPass].merge(@pass.boarding_pass)
end

File.write(@temporary_path.join("pass.json"), pass.to_json)
pass
end

# rubocop:enable Metrics/AbcSize
Expand Down
2 changes: 1 addition & 1 deletion lib/passkit/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Passkit
VERSION = "0.7.0"
VERSION = "0.8.0"
end
164 changes: 164 additions & 0 deletions test/test_generator_poster.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# frozen_string_literal: true

require "test_helper"
require "active_support/all"
require "delegate"
require "tmpdir"

# Generator resolves Rails paths when it loads; the payload itself needs no app.
# Rake loads every test file into one process, so only stand in for Rails.root
# when nothing else has booted an app -- otherwise this would repoint the
# dummy app the controller tests rely on.
ENV["PASSKIT_APPLE_INTERMEDIATE_CERTIFICATE"] ||= "AppleWWDRCA.cer"
unless Rails.root
def Rails.root
@root ||= Pathname.new(Dir.mktmpdir)
end
end

class TestGeneratorPoster < Minitest::Test
# A pass that opts into the poster layout on top of the classic one.
class PosterCard < Passkit::ExampleStoreCard
def pass_type
:generic
end

def poster_pass_type
:posterGeneric
end

def primary_fields
[{key: "balance", value: "$25"}]
end

def poster_primary_fields
[{key: "memberName", label: "Member Name", value: "Juan Chavez"}]
end

def footer_fields
[{key: "membershipType", value: "Family Pass"}]
end

def featured_actions
[{identifier: "redeem", type: "shop", url: "https://example.com/redeem"}]
end
end

# Opts into the poster layout without saying what goes in its primary row.
class DefaultPosterCard < Passkit::ExampleStoreCard
def poster_pass_type
:posterGeneric
end

def primary_fields
[{key: "balance", value: "$25"}]
end
end

# boarding_pass carries the keys only this style has, e.g. transitType.
class BoardingCard < Passkit::ExampleStoreCard
def pass_type
:boardingPass
end

def boarding_pass
{transitType: "PKTransitTypeGeneric"}
end
end

# featured_actions has a [] default, but a subclass is free to return nil.
class NilActionsCard < Passkit::ExampleStoreCard
def featured_actions
end
end

# Stands in for Passkit::Pass, which only adds persisted columns on top of
# the pass instance.
class PassDouble < SimpleDelegator
def generator
nil
end

def authentication_token
"authentication-token"
end

def serial_number
"serial-number"
end

def sharing
nil
end

def web_service_url
"https://example.com/passkit/api"
end

def apple_team_identifier
"TEAMIDENTIFIER"
end

def [](key)
nil
end
end

def pass_json_for(pass_class)
generator = Passkit::Generator.new(PassDouble.new(pass_class.new))
generator.send(:pass_json, "pass.com.example.card")
end

def test_poster_dictionary_is_emitted_alongside_the_classic_one
json = pass_json_for(PosterCard)

assert_equal [{key: "membershipType", value: "Family Pass"}], json[:posterGeneric][:footerFields]
assert_equal [{key: "memberName", label: "Member Name", value: "Juan Chavez"}], json[:posterGeneric][:primaryFields]
assert_equal json[:generic][:headerFields], json[:posterGeneric][:headerFields]
assert_equal json[:generic][:backFields], json[:posterGeneric][:backFields]
end

def test_poster_dictionary_carries_no_secondary_or_auxiliary_rows
assert_equal %i[headerFields primaryFields footerFields backFields], pass_json_for(PosterCard)[:posterGeneric].keys
end

# The classic layout puts primary fields above the barcode, the poster below it.
def test_poster_primary_fields_are_independent_of_the_classic_ones
json = pass_json_for(PosterCard)

assert_equal [{key: "balance", value: "$25"}], json[:generic][:primaryFields]
refute_equal json[:generic][:primaryFields], json[:posterGeneric][:primaryFields]
end

def test_featured_actions_are_emitted
json = pass_json_for(PosterCard)

assert_equal [{identifier: "redeem", type: "shop", url: "https://example.com/redeem"}], json[:featuredActions]
end

def test_passes_without_a_poster_style_are_unchanged
json = pass_json_for(Passkit::ExampleStoreCard)

assert json.key?(:storeCard)
refute json.key?(:posterGeneric)
refute json.key?(:featuredActions)
end

def test_poster_primary_fields_default_to_the_classic_ones
json = pass_json_for(DefaultPosterCard)

assert_equal [{key: "balance", value: "$25"}], json[:posterGeneric][:primaryFields]
assert_equal json[:storeCard][:primaryFields], json[:posterGeneric][:primaryFields]
end

def test_boarding_pass_keys_are_merged_into_the_style_dictionary
json = pass_json_for(BoardingCard)

assert_equal "PKTransitTypeGeneric", json[:boardingPass][:transitType]
assert_equal BoardingCard.new.header_fields, json[:boardingPass][:headerFields]
end

def test_nil_featured_actions_emit_no_key
refute pass_json_for(NilActionsCard).key?(:featuredActions)
end
end