diff --git a/CHANGELOG.md b/CHANGELOG.md index 2310c04..04108a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/models/passkit/pass.rb b/app/models/passkit/pass.rb index 3ccd837..e82acb5 100644 --- a/app/models/passkit/pass.rb +++ b/app/models/passkit/pass.rb @@ -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, @@ -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, diff --git a/lib/passkit/base_pass.rb b/lib/passkit/base_pass.rb index 41e0a75..93afcec 100644 --- a/lib/passkit/base_pass.rb +++ b/lib/passkit/base_pass.rb @@ -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 diff --git a/lib/passkit/generator.rb b/lib/passkit/generator.rb index 69e27ea..32e8be0 100644 --- a/lib/passkit/generator.rb +++ b/lib/passkit/generator.rb @@ -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, @@ -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, @@ -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 diff --git a/lib/passkit/version.rb b/lib/passkit/version.rb index 628d551..c9233a9 100644 --- a/lib/passkit/version.rb +++ b/lib/passkit/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Passkit - VERSION = "0.7.0" + VERSION = "0.8.0" end diff --git a/test/test_generator_poster.rb b/test/test_generator_poster.rb new file mode 100644 index 0000000..557b0b6 --- /dev/null +++ b/test/test_generator_poster.rb @@ -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