From d27c138b4cea6a5d2831db6ee3c56413cbeb76da Mon Sep 17 00:00:00 2001 From: Vitalii Oliinyk Date: Wed, 26 Aug 2026 01:21:37 +0300 Subject: [PATCH 1/4] [PR-26997] Support Apple poster pass styles Emit a poster style dictionary (posterGeneric, iOS 27+) alongside the classic one so a single pass renders everywhere: devices that know the poster key use it, older ones ignore it and fall back to pass_type. Poster layouts drop the secondary/auxiliary rows for a single footer field, so the dictionary carries header, footer and back fields only. Also adds featured_actions (PR-26998) to the pass surface, and fixes the boardingPass merge silently discarding its result. --- app/models/passkit/pass.rb | 3 ++ lib/passkit/base_pass.rb | 19 ++++++++ lib/passkit/generator.rb | 22 ++++++++- lib/passkit/version.rb | 2 +- test/test_generator.rb | 96 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 test/test_generator.rb diff --git a/app/models/passkit/pass.rb b/app/models/passkit/pass.rb index 3ccd837..a489cda 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,7 @@ class Pass < ActiveRecord::Base :pass_path, :pass_type, :pass_type_identifier, + :poster_pass_type, :primary_fields, :relevant_date, :secondary_fields, diff --git a/lib/passkit/base_pass.rb b/lib/passkit/base_pass.rb index 41e0a75..1a71b93 100644 --- a/lib/passkit/base_pass.rb +++ b/lib/passkit/base_pass.rb @@ -223,6 +223,25 @@ def sharing_prohibited false end + # Poster style dictionary to emit alongside pass_type, e.g. :posterGeneric (iOS 27+). + # 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 + + # 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: "link", 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..54ca411 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,8 @@ 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] + pass[:featuredActions] = @pass.featured_actions if @pass.featured_actions.any? + pass[@pass.pass_type] = { headerFields: @pass.header_fields, primaryFields: @pass.primary_fields, @@ -113,9 +119,21 @@ 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, + 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.rb b/test/test_generator.rb new file mode 100644 index 0000000..d9d5cbc --- /dev/null +++ b/test/test_generator.rb @@ -0,0 +1,96 @@ +# 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. +ENV["PASSKIT_APPLE_INTERMEDIATE_CERTIFICATE"] ||= "AppleWWDRCA.cer" +def Rails.root + @root ||= Pathname.new(Dir.mktmpdir) +end + +class TestGenerator < 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 footer_fields + [{key: "membershipType", value: "Family Pass"}] + end + + def featured_actions + [{identifier: "redeem", type: "shop", url: "https://example.com/redeem"}] + 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 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 footerFields backFields], pass_json_for(PosterCard)[:posterGeneric].keys + 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 +end From d3900105d235a7fd57be0d192fb4ab161ff4405d Mon Sep 17 00:00:00 2001 From: Vitalii Oliinyk Date: Thu, 27 Aug 2026 13:26:04 +0300 Subject: [PATCH 2/4] [PR-26997] Emit poster primary fields The posterGeneric layout renders header -> barcode -> primary -> footer, but the poster dictionary only carried header/footer/back, so poster passes drew an empty primary row. Add a poster_primary_fields hook (defaulting to the classic primary_fields) and emit it. The classic and poster dictionaries ship in the same pass.json, so one accessor cannot serve both slots -- the Talkable app maps its secondary_fields to the poster primaries. Also fix the featured_actions doc example: "link" is not one of Apple's featured action types. Tests move to test/test_generator_poster.rb / TestGeneratorPoster so they no longer collide with the test/test_generator.rb added by PR #4, and the Rails.root stub is now guarded so it cannot repoint the dummy app that the controller tests boot in the same rake process. --- app/models/passkit/pass.rb | 1 + lib/passkit/base_pass.rb | 9 +++++- lib/passkit/generator.rb | 1 + ..._generator.rb => test_generator_poster.rb} | 30 ++++++++++++++++--- 4 files changed, 36 insertions(+), 5 deletions(-) rename test/{test_generator.rb => test_generator_poster.rb} (66%) diff --git a/app/models/passkit/pass.rb b/app/models/passkit/pass.rb index a489cda..e82acb5 100644 --- a/app/models/passkit/pass.rb +++ b/app/models/passkit/pass.rb @@ -37,6 +37,7 @@ class Pass < ActiveRecord::Base :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 1a71b93..b96719c 100644 --- a/lib/passkit/base_pass.rb +++ b/lib/passkit/base_pass.rb @@ -235,9 +235,16 @@ 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: "link", url: "https://example.com"} + # i.e {identifier: "redeem", type: "shop", url: "https://example.com"} def featured_actions [] end diff --git a/lib/passkit/generator.rb b/lib/passkit/generator.rb index 54ca411..d64f1d3 100644 --- a/lib/passkit/generator.rb +++ b/lib/passkit/generator.rb @@ -124,6 +124,7 @@ def pass_json(pass_type_identifier) 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 } diff --git a/test/test_generator.rb b/test/test_generator_poster.rb similarity index 66% rename from test/test_generator.rb rename to test/test_generator_poster.rb index d9d5cbc..830555d 100644 --- a/test/test_generator.rb +++ b/test/test_generator_poster.rb @@ -6,12 +6,17 @@ 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" -def Rails.root - @root ||= Pathname.new(Dir.mktmpdir) +unless Rails.root + def Rails.root + @root ||= Pathname.new(Dir.mktmpdir) + end end -class TestGenerator < Minitest::Test +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 @@ -22,6 +27,14 @@ 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 @@ -72,12 +85,21 @@ 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 footerFields backFields], pass_json_for(PosterCard)[:posterGeneric].keys + 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 From 3cf5688f8e6d9681495afb3e27e44904410dceda Mon Sep 17 00:00:00 2001 From: Vitalii Oliinyk Date: Thu, 27 Aug 2026 13:47:24 +0300 Subject: [PATCH 3/4] [PR-26997] Cover the boarding pass merge and the poster field default Add a 0.8.0 changelog entry. It names the poster hooks and the boarding_pass fix, which is a behaviour change: those keys used to be merged into a discarded copy of the boardingPass dictionary and never reached pass.json. Test that fix, and test that poster_primary_fields falls back to primary_fields -- PosterCard overrides the hook, so the default was uncovered. Name posterGeneric as the only poster style Wallet takes as a top-level key. posterEventTicket is a preferredStyleSchemes entry, not a style dictionary. --- CHANGELOG.md | 8 ++++++++ lib/passkit/base_pass.rb | 4 +++- test/test_generator_poster.rb | 36 +++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) 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/lib/passkit/base_pass.rb b/lib/passkit/base_pass.rb index b96719c..93afcec 100644 --- a/lib/passkit/base_pass.rb +++ b/lib/passkit/base_pass.rb @@ -223,7 +223,9 @@ def sharing_prohibited false end - # Poster style dictionary to emit alongside pass_type, e.g. :posterGeneric (iOS 27+). + # 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 diff --git a/test/test_generator_poster.rb b/test/test_generator_poster.rb index 830555d..519e51a 100644 --- a/test/test_generator_poster.rb +++ b/test/test_generator_poster.rb @@ -44,6 +44,28 @@ def featured_actions 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 + # Stands in for Passkit::Pass, which only adds persisted columns on top of # the pass instance. class PassDouble < SimpleDelegator @@ -115,4 +137,18 @@ def test_passes_without_a_poster_style_are_unchanged 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 end From 122af06e8bcac1a6566031deca556e39054ee614 Mon Sep 17 00:00:00 2001 From: Vitalii Oliinyk Date: Thu, 27 Aug 2026 13:52:52 +0300 Subject: [PATCH 4/4] [PR-26997] Guard featured_actions against nil Every other optional key in pass_json uses a plain truthy check. This one called the hook twice and used .any?, which raises NoMethodError when a subclass returns nil instead of the [] default. present? covers both nil and [], so the default still emits no key. --- lib/passkit/generator.rb | 3 ++- test/test_generator_poster.rb | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/passkit/generator.rb b/lib/passkit/generator.rb index d64f1d3..32e8be0 100644 --- a/lib/passkit/generator.rb +++ b/lib/passkit/generator.rb @@ -109,7 +109,8 @@ def pass_json(pass_type_identifier) pass[:userInfo] = @pass.user_info if @pass.user_info pass[:sharing] = @pass.sharing if @pass.sharing && !@pass[:sharing_prohibited] - pass[:featuredActions] = @pass.featured_actions if @pass.featured_actions.any? + featured_actions = @pass.featured_actions + pass[:featuredActions] = featured_actions if featured_actions.present? pass[@pass.pass_type] = { headerFields: @pass.header_fields, diff --git a/test/test_generator_poster.rb b/test/test_generator_poster.rb index 519e51a..557b0b6 100644 --- a/test/test_generator_poster.rb +++ b/test/test_generator_poster.rb @@ -66,6 +66,12 @@ def boarding_pass 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 @@ -151,4 +157,8 @@ def test_boarding_pass_keys_are_merged_into_the_style_dictionary 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