├── .git-blame-ignore-revs
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── config.yml
├── pull_request_template.md
└── workflows
│ └── run-tests.yml
├── .gitignore
├── .nvmrc
├── .prettierrc
├── .vscode
└── settings.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── assets
├── logo-dark.afdesign
├── logo-dark.svg
├── logo-light.svg
└── logo.svg
├── examples
├── cloudflare-worker
│ ├── .gitignore
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── files.d.ts
│ │ └── index.ts
│ ├── tsconfig.json
│ └── wrangler.toml
├── firebase
│ ├── .firebaserc
│ ├── .gitignore
│ ├── README.md
│ ├── firebase.json
│ ├── functions
│ │ ├── .env
│ │ ├── .gitignore
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── index.ts
│ │ │ └── startData.json
│ │ └── tsconfig.json
│ └── storage.rules
├── models
│ ├── exampleBooking.pass
│ │ ├── footer.png
│ │ ├── footer@2x.png
│ │ ├── icon.png
│ │ ├── icon@2x.png
│ │ ├── logo.png
│ │ ├── logo@2x.png
│ │ └── pass.json
│ ├── examplePass.pass
│ │ ├── background.png
│ │ ├── background@2x.png
│ │ ├── de.lproj
│ │ │ ├── icon.png
│ │ │ ├── icon@2x.png
│ │ │ ├── thumbnail.png
│ │ │ └── thumbnail@2x.png
│ │ ├── icon.png
│ │ ├── icon@2x.png
│ │ ├── it.lproj
│ │ │ ├── icon.png
│ │ │ ├── icon@2x.png
│ │ │ ├── thumbnail.png
│ │ │ └── thumbnail@2x.png
│ │ ├── logo.png
│ │ ├── logo@2x.png
│ │ ├── pass.json
│ │ ├── thumbnail.png
│ │ └── thumbnail@2x.png
│ ├── glowTime.pass
│ │ ├── artwork.png
│ │ ├── artwork@2x.png
│ │ ├── artwork@3x.png
│ │ ├── icon.png
│ │ ├── icon@2x.png
│ │ ├── logo.png
│ │ ├── logo@2x.png
│ │ ├── pass.json
│ │ ├── secondaryLogo.png
│ │ └── secondaryLogo@2x.png
│ ├── posterEventTicketWithAdmissionLevel.pass
│ │ ├── background.png
│ │ ├── icon.png
│ │ ├── logo.png
│ │ ├── pass.json
│ │ └── secondaryLogo.png
│ └── posterEventTicketWithNewSeats.pass
│ │ ├── artwork.png
│ │ ├── artwork@3x.png
│ │ ├── icon.png
│ │ ├── logo.png
│ │ ├── pass.json
│ │ ├── strip@3x.png
│ │ └── venueMap.png
├── self-hosted
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── PKPass.from.ts
│ │ ├── PKPasses.ts
│ │ ├── fields.ts
│ │ ├── index.ts
│ │ ├── localize.ts
│ │ ├── scratch.ts
│ │ ├── setBarcodes.ts
│ │ ├── setExpirationDate.ts
│ │ ├── shared.ts
│ │ └── webserver.ts
│ └── tsconfig.json
└── serverless
│ ├── .gitignore
│ ├── README.md
│ ├── config.json
│ ├── package.json
│ ├── serverless.yml
│ ├── src
│ ├── functions
│ │ ├── barcodes.ts
│ │ ├── expirationDate.ts
│ │ ├── fields.ts
│ │ ├── index.ts
│ │ ├── localize.ts
│ │ ├── pkpasses.ts
│ │ └── scratch.ts
│ ├── index.ts
│ └── shared.ts
│ └── tsconfig.json
├── jest.config.mjs
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── specs
├── PKPass.spec.mjs
└── utils.spec.mjs
├── src
├── Bundle.ts
├── FieldsArray.ts
├── PKPass.ts
├── Signature.ts
├── StringsUtils.ts
├── getModelFolderContents.ts
├── index.ts
├── messages.ts
├── schemas
│ ├── Barcode.ts
│ ├── Beacon.ts
│ ├── Certificates.ts
│ ├── Field.ts
│ ├── Location.ts
│ ├── NFC.ts
│ ├── PassFields.ts
│ ├── Personalize.ts
│ ├── SemanticTagType.ts
│ ├── Semantics.ts
│ ├── index.ts
│ └── regexps.ts
└── utils.ts
├── tsconfig.cjs.json
├── tsconfig.esm.json
└── tsconfig.json
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # Prettier rewrite
2 | 0827730d410d0491bd271afa4381b52176166c0f
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | open_collective: passkit-generator
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: If you suspect to have found a bug in passkit-generator, open a bug report. Thanks for helping passkit-generator improve!
4 | title: ""
5 | labels: ["Needs Triage"]
6 | assignees: ""
7 | ---
8 |
9 | ## Running OS
10 |
11 |
12 |
13 | ## Running Node Version
14 |
15 |
19 |
20 | ## Description
21 |
22 |
23 |
24 | ## Expected behavior
25 |
26 |
39 |
40 | ## Steps to reproduce
41 |
42 |
46 |
47 |
56 |
57 | ## Were you able to verify it by using (and changing) the examples?
58 |
59 |
60 |
61 |
62 | ## If yes, which changes did you apply?
63 |
64 |
65 |
66 | ## Other details
67 |
68 | - [ ] I'm available to open a Pull Request to resolve the problem (after the triage)
69 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 | contact_links:
3 | - name: Feature Request
4 | about: Propose a new feature in discussions. Click the link to open one with a template
5 | url: https://github.com/alexandercerutti/passkit-generator/discussions/new?category=ideas&title=[Feature%20Request]%20
6 |
7 | - name: Security Issues
8 | about: Contact me privately on Telegram for security issues
9 | url: https://t.me/AlexandrCerutti
10 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
9 |
10 | ## Description
11 |
12 |
21 |
22 | ## Check relevant checkboxes
23 |
24 | - [ ] I've run tests (through `npm test`) and they passed
25 | - [ ] I generated a working Apple Wallet Pass after the change
26 | - [ ] Provided examples keep working after the change
27 | - [ ] This improvement is or might be a breaking change
28 |
29 | ## Relevant information
30 |
31 |
32 |
--------------------------------------------------------------------------------
/.github/workflows/run-tests.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - master
5 |
6 | pull_request:
7 | types: [opened, edited]
8 | branches:
9 | - master
10 |
11 | workflow_dispatch:
12 |
13 | jobs:
14 | test-on-ubuntu:
15 | name: Testing Workflow Linux
16 | runs-on: ubuntu-latest
17 | env:
18 | SIGNER_CERT: ${{ secrets.SIGNER_CERT }}
19 | SIGNER_KEY: ${{ secrets.SIGNER_KEY }}
20 | WWDR: ${{ secrets.WWDR }}
21 | SIGNER_KEY_PASSPHRASE: ${{ secrets.SIGNER_KEY_PASSPHRASE }}
22 | steps:
23 | - uses: pnpm/action-setup@v4
24 | with:
25 | version: 9
26 | - uses: actions/checkout@v3
27 | - uses: actions/setup-node@v3
28 | with:
29 | node-version: "20.x"
30 | check-latest: true
31 | - run: |
32 | pnpm install
33 | pnpm build
34 | pnpm test
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | passModels/
4 | certificates/
5 | *.code-workspace
6 | .vscode/*
7 | !.vscode/settings.json
8 | *.js
9 | lib/
10 | examples/build
11 | spec/**/*.js
12 | spec/**/*.js.map
13 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": true,
3 | "trailingComma": "all",
4 | "tabWidth": 4,
5 | "useTabs": true
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.tabSize": 4,
3 | "editor.formatOnSave": true,
4 | "editor.insertSpaces": false,
5 | "editor.smoothScrolling": true,
6 | "editor.defaultFormatter": "esbenp.prettier-vscode",
7 |
8 | "jest.jestCommandLine": "NODE_OPTIONS=\"--warnings\" pnpm jest -c jest.config.cjs --runInBand --silent"
9 | }
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ### 3.4.0 (28 May 2025)
4 |
5 | - Added support to undocumented feature `stripColor` (PR #245)
6 |
7 | ---
8 |
9 | ### 3.3.0 (11 Jan 2025)
10 |
11 | - Added support to the missing iOS 18 changes (`useAutomaticColor`, `footerBackgroundColor`, `suppressHeaderDarkening`, `auxiliaryStoreIdentifiers`, `eventStartDateInfo`, `venueOpenDate`);
12 | - Added support to `relevantDate` property in `relevantDates`, along with `startDate` and `endDate`, in `pass.json`;
13 | - Added new method `setRelevantDates`;
14 | - Improved details on `(root).relevantDate` deprecation since iOS 18;
15 | - Improved comments and markers on SemanticTags with iOS version;
16 | - Added support to double compilation ESM + CJS: both are now shipped;
17 | - Updated examples. This update brings also them to run on ESM;
18 | - Made easier to run examples and install their dependency by converting repo to be a pnpm workspace;
19 |
20 | ---
21 |
22 | ### 3.2.0 (29 Oct 2024)
23 |
24 | - Added support to iOS 18 changes (refer to [issue #205](https://github.com/alexandercerutti/passkit-generator/issues/205) for all the changes);
25 | - Added support to hex colors for `foregroundColor`, `backgroundColor`, `labelColor` as well as new iOS 18 color properties;
26 | - Added new example models for iOS 18 changes;
27 | - Added inline source maps in files;
28 | - Fixed `Field.timeStyle` typescript type;
29 | - Changed all node imports to use `node:` prefix;
30 | - Changes `do-not-zip` usage make use of explict `node:buffer` import;
31 |
32 | ---
33 |
34 | ### 3.1.11 (15 Aug 2023)
35 |
36 | - Fixed beacons `major` validation to be more relaxed (PR #158);
37 |
38 | ---
39 |
40 | ### 3.1.10 (09 Aug 2023)
41 |
42 | - Fixed dates processing by converting them to UTC (PR #155);
43 |
44 | ---
45 |
46 | ### 3.1.9 (03 Apr 2023)
47 |
48 | - Fixed transitType which wasn't being imported when a boardingPass was getting read (PR #138)
49 | - Improved types for property in Field type (PR #139)
50 |
51 | ---
52 |
53 | ### 3.1.8 (26 Mar 2023)
54 |
55 | - Fixed Typescript type for Semantics.WifiAccess (PR #136)
56 |
57 | ---
58 |
59 | ### 3.1.7 (14 Nov 2022)
60 |
61 | - Fixed generation of EventTicket with row fields (PR #118)
62 |
63 | ---
64 |
65 | ### 3.1.6 (29 Mar 2022)
66 |
67 | - Optimizations for localizationEntries, PKPass.pack, localize and regexes;
68 | - Dependencies Update;
69 |
70 | ---
71 |
72 | ### 3.1.5 (22 Feb 2022)
73 |
74 | - Fixed FieldsArray order when pushing or unshifting fields in `headerFields`, `primaryFields`, `secondaryFields`, `auxiliaryFields` and `backFields` (PR #104)
75 |
76 | ---
77 |
78 | ### 3.1.4 (07 Feb 2022)
79 |
80 | - Fixed Schema validation for browser-like contexts like Cloudflare Workers (PR #100);
81 |
82 | - Added examples for Cloudflare Workers;
83 |
84 | ---
85 |
86 | ### 3.1.3 (16 Jan 2022)
87 |
88 | - Updated dependencies to remove dependabot alerts (like, node-forge to v1.2.1);
89 | - Updated tests;
90 |
91 | ---
92 |
93 | ### 3.1.2 (30 Dec 2021 (Happy new year!))
94 |
95 | - This release fixes some issues when running under Windows and adds new tests.
96 | - Thanks to PR #99 by d34db4b3.
97 |
98 | ---
99 |
100 | ### 3.1.1 (25 Dec 2021 (Merry Christmas!))
101 |
102 | - This release fixes some issues with typescript strict mode (as much as we were able to fix without starting ho-ho-ho-ing due to madness 🤪).
103 |
104 | ---
105 |
106 | ### 3.1.0 (11 Dec 2021)
107 |
108 | - Made `PKPass.from` Template `certificates` to be optional;
109 | - Changed constructor buffers and certificates to be optional;
110 | - Added constructor check on certificates to avoid error if pass is created through `PKPass.from` but without certificates;
111 | - Added constructor checks for buffers with a warning being fired if the passed parameter is not an object;
112 |
113 | ---
114 |
115 | ### 3.0.0 / 3.0.1 (31 Oct 2021)
116 |
117 | - Passkit-generator has been completely refactored and re-conceptualized. Follow [Migration Guide v2 to v3](https://github.com/alexandercerutti/passkit-generator/wiki/Migrating-from-v2-to-v3) to see the differences between the two versions
118 |
119 | ---
120 |
121 | ### 2.0.8 (25 Aug 2021)
122 |
123 | - Added support for optional NFC key `requiresAuthentication`;
124 | - Added support for semantics as a global overridable property;
125 | - Renamed files to conform to Apple naming in documentation;
126 | - Added documentation links in files;
127 |
128 | ---
129 |
130 | ### 2.0.7 (21 Jun 2021)
131 |
132 | - Fixed wrong Schemas keys (`ignoresTimeZone` and `dataDetectorTypes`);
133 | - Added more SemanticsTagTypes
134 | - Refactored Error system;
135 | - Refactored Schemas;
136 | - Updated Dependencies;
137 | - Removed unnecessary ways to perfom ways in refactoring;
138 |
139 | ---
140 |
141 | ### 2.0.6 (09 Feb 2021)
142 |
143 | - Improved building phase;
144 | - Improved tests;
145 | - Updated dependencies (like node-forge and node-fetch, which had critical vulnerability);
146 | - Added prettier for formatting;
147 | - Generic improvements to code;
148 | - Removed moment.js for an internal generation of the date (without timezone support);
149 |
150 | ---
151 |
152 | ### 2.0.5 (06 Sep 2020)
153 |
154 | - Replaced deprecated dependencies `@hapi/joi` with Sideway's joi;
155 | - Generic dependencies update;
156 | - Generic code improvements (vscode-autofixes included);
157 | - Bumped minimum Node.JS supported version to 10 (moved from `util.promisify` approach to `fs.promises`);
158 |
159 | ---
160 |
161 | ### 2.0.4 (14 Dec 19)
162 |
163 | - Typescript strict configuration fixes;
164 | - Improved specifications;
165 |
166 | ---
167 |
168 | ### 2.0.3 (06 Dec 19)
169 |
170 | - Dependencies Updates;
171 | - More improvements;
172 |
173 | ---
174 |
175 | ### 2.0.2
176 |
177 | - Unlocked some other prohibited (until now) fields that were not editable due to design choice ( `organizationName`, `passTypeIdentifier`, `teamIdentifier`, `appLaunchURL`, `associatedStoreIdentifiers`);
178 | - Small improvements;
179 |
180 | ---
181 |
182 | ### 2.0.1
183 |
184 | - Typescript version update;
185 | - Update to webServiceURL schema regex and allowed all characters for authenticationToken;
186 |
187 | ---
188 |
189 | ### 2.0.0
190 |
191 | This version brings lot of improvements and breaking changes.
192 | Please refer to the [Migration Guide](https://github.com/alexandercerutti/passkit-generator/wiki/Migrating-from-v1-to-v2) for the most important changes.
193 |
194 | ---
195 |
196 | ### 1.6.8
197 |
198 | - Added optional `row` attribute for `auxiliaryFields`
199 |
200 | ---
201 |
202 | ### 1.6.6
203 |
204 | - Fixed problem with fieldsArray: fields were being added even if the keys check was failing
205 |
206 | ---
207 |
208 | ### 1.6.5
209 |
210 | - Added support for `logoText` in `supportedOptions` (issues #21, #28)
211 | - Fixed nfc methods which was accepting and registering an array instead of an object
212 | - Adding support for native Dates (#32)
213 | - Fixing passes parallel generation (#31)
214 |
215 | ---
216 |
217 | ### 1.6.4
218 |
219 | - Added windows path slash parsing
220 |
221 | ---
222 |
223 | ### 1.6.3
224 |
225 | - Moved some utility functions to a separate file
226 | - Removed rgbValues as a variable for a direct approact
227 | - Renamed `_validateType` in `_hasValidType`
228 | - Fixed barcode legacy bug
229 | - Added NO_PASS_TYPE as message
230 | - Moved passExtractor function to class scope instead of generate()'s
231 | - Moved to async/await approach for generate()
232 |
233 | ---
234 |
235 | ### 1.6.0
236 |
237 | - Improved unique fields management;
238 | - Changed debug message for discarded fields;
239 | - Renamed uniqueKeys to fieldsKeys
240 | - Added `BRC_BW_FORMAT_UNSUPPORTED` to not let `PKBarcodeFormatCode128` to be used as backward barcode format
241 | - Added support for row field in `auxiliaryFields`
242 | - Added support to `semantics` keys to fields in schema
243 |
244 | ---
245 |
246 | ### 1.5.9
247 |
248 | - Removed check for changeMessage as per issue topic #15
249 | - Added pass.strings file concatenation with translations if it already exists
250 | in specific folder;
251 | - Small changes to messages;
252 |
253 | ---
254 |
255 | ### 1.5.8
256 |
257 | - Now checking both static list and remote list before raising the error for missing files
258 | - (thank you, Artsiom Aliakseyenka);
259 | - Renamed `__barcodeAutogen` to barcodesFromUncompleteData and moved it outside of Pass class;
260 | - Renamed `__barcodeAutocomplete` to `Symbol/barcodesFillMissing`;
261 | - Renamed `__barcodeChooseBackward` to `Symbol/barcodesSetBackward`;
262 | - Removed context binding when passing above methods with alises after using .barcode();
263 | - Edited BRC_ATC_MISSING_DATA message
264 |
265 | ---
266 |
267 | ### 1.5.7
268 |
269 | - Moved tests to spec folder with jasmine configuration
270 | - Fixed barcodes validation problem
271 | - Re-engineered FieldContainer (now FieldsArray) to extend successfully array with its methods.
272 |
273 | ---
274 |
275 | ### 1.5.6
276 |
277 | - Updated documentation
278 | - Added content-certificates support;
279 | - Fixed problem with supported options
280 | - Added description to be available for override (thank you, Artsiom Aliakseyenka);
281 |
282 | ---
283 |
284 | ### 1.5.5
285 |
286 | - Schema: changed `webServiceURL` Regex and `authenticationToken` binding to this one
287 | - Schema: removed filter function for getValidated to return empty object in case of error;
288 | - Added `OVV_KEYS_BADFORMAT` message to throw in case of error;
289 |
290 | ---
291 |
292 | ### 1.5.4
293 |
294 | - Added .npmignore to exclude examples upload
295 | - Replaced findIndex for find to get directly the pass type.
296 | - Added function assignLength to wrap new objects with length property.
297 | - Converted schemas arrow functions to functions and added descriptive comments.
298 | - Added noop function instead creating new empty functions.
299 |
300 | ---
301 |
302 | ### 1.5.3
303 |
304 | - Bugfix: when overrides is not passed as option, the pass does not get generated.
305 |
306 | ---
307 |
308 | ### 1.5.2
309 |
310 | - Added schema support for sharingProhibited (not documented in ppfr)
311 |
312 | ---
313 |
314 | ### 1.5.1
315 |
316 | - Updated declaration file
317 | - Fixed problem in error message resolving on multiple %s;
318 | - Added debug messages in messages.js;
319 | - Added more comments;
320 | - Moved literal debug messages to messages.js;
321 | - Edited formatMessage (was formatError) to check also among debugMessages
322 |
323 | ---
324 |
325 | ### 1.5.0
326 |
327 | - Moved `_parseCertificates` outside of pass and renamed it in readCertificates;
328 | - Changed `readCertificates` to return object containing name:parsed-pem;
329 | - Added `readCertificates` and `this.Certificates` merging before model reading;
330 |
331 | ---
332 |
333 | ### 1.4.2
334 |
335 | - Minor changes to READMEs and core.
336 | - Updated documentation
337 |
338 | ---
339 |
340 | ### 1.4.1
341 |
342 | - Fix model initialization validation
343 | - Improved README
344 | - Added logo in assets and README
345 | - Added updates for OpenSSL for Windows in termal steps for cers generation
346 | - Updated dependencies minimum version
347 |
348 | ---
349 |
350 | ### 1.4.0
351 |
352 | - Added working example for load
353 | - Fix typos for non-mac guide
354 | - Removed `express` from dev dependencies;
355 | - Added `.load` type definition
356 | - Added `.load` to documentation;
357 | - Added `.load` function to fetch pictures from the web and implemented fetching function inside logic flow
358 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Alexander P. Cerutti
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
14 |
15 |
16 |
17 |
Simple Node.js interface to generate customized Apple Wallet Passes for iOS and WatchOS.
18 |
19 | 
20 | 
21 |
22 | [](https://opencollective.com/passkit-generator)
23 |
24 |
25 |
26 |
27 | ## Architecture
28 |
29 | This library was created with a specific architecture in mind: **application** and **model** (as preprocessed entity), to split as much as possible static objects (such as logo, background, icon, etc.) from dynamic ones (translations, barcodes, serialNumber, ...), while keeping an eye on the different possible execution contexts.
30 |
31 | Pass creation and population might not fully happen in runtime. This library allows to create a pass from scratch, specify a folder model (template) or specify a set of buffers. In the last two cases, both should contain all the objects needed (static medias) and structure to make a pass work.
32 |
33 | Whenever adding files, through scratch, template or buffer, these will be read and pushed as they are in the resulting .zip file, while dynamic data will be patched (`pass.json` with props) or generated in runtime (`manifest.json`, `signature` and translation files).
34 |
35 | ### Installation
36 |
37 | ```sh
38 | $ npm install passkit-generator --save
39 | ```
40 |
41 | ---
42 |
43 | ### API Documentation
44 |
45 | This package comes with an [API Documentation Reference](https://github.com/alexandercerutti/passkit-generator/wiki/API-Documentation-Reference), available in wiki, that makes available a series of methods to create and customize passes.
46 |
47 | ---
48 |
49 | ### Looking for the previous major version?
50 |
51 | Check the [v2 tag](https://github.com/alexandercerutti/passkit-generator/tree/v2.0.8). That tag is kept for reference only.
52 |
53 | ---
54 |
55 | ### Coming from the previous major version?
56 |
57 | Look at the [Migration Guide](https://github.com/alexandercerutti/passkit-generator/wiki/Migrating-from-v2-to-v3).
58 |
59 | ---
60 |
61 | ### Integrating Apple Wallet Web services?
62 |
63 | Give a look at other libreries I made:
64 |
65 | - [Passkit Webservice Toolkit](https://github.com/alexandercerutti/passkit-webservice-toolkit) and its integrations
66 | - 🚧 [hapns](https://github.com/alexandercerutti/hapns) to support updates through APNs notifications, with examples (early stage of development, feedbacks are welcome)
67 |
68 | ---
69 |
70 | ## Getting Started
71 |
72 | ##### Model
73 |
74 | Assuming that you don't have a model yet, the first thing you'll have to do, is creating one. A model contains all the basic pass data that compose the Pass identity.
75 | These data can be files (icon, thumbnails, ...), or pieces of information to be written in `pass.json` (Pass type identifier, Team Identifier, colors, ...) and whatever you know that likely won't be customized on runtime.
76 |
77 | When starting from zero, the best suggested solution is to use a Template (folder) to start with, as it will allow an easier access to all the files and data. Nothing will prevent you using a buffer model or creating a pass from scratch, but they are meant for an advanced usage or different contexts (e.g. running a cloud function might require a scratch model for faster startup, without storing the model in a "data bucket").
78 |
79 | Let's suppose you have a file `model.zip` stored somewhere: you unzip it in runtime and then get the access to its files as buffers. Those buffers should be available for the rest of your application run-time and you shouldn't be in need to read them every time you are going to create a pass.
80 |
81 | **To maintain a pass model available during the run-time, a PKPass instance can be created from whatever source, and then used as a template through `PKPass.from`**.
82 |
83 | > Using the .pass extension is a best practice, showing that the directory is a pass package.
84 | > ([Build your first pass - Apple Developer Portal](https://apple.co/2LYXWo3)).
85 |
86 | Following to this best practice, the package is set to **require** each folder-model to have a **_.pass_** extension.
87 |
88 | If omitted in the configuration (as in [Usage Example](#usage_example) below), it will be forcefully added, possibly resulting in a folder reading error, if your model folder doesn't have it.
89 |
90 | ---
91 |
92 | Model creation can be performed both manually or with the auxiliary of a web tool I developed, [Passkit Visual Designer](https://pkvd.app), which will let you design your model through a neat user interface.
93 | It will output a .zip file that you can decompress and use as source.
94 |
95 | ---
96 |
97 | You can follow [_Create the Directory and add Files for the Pass_](https://apple.co/3zumjFI) at Apple Developer to build a correct pass model. The **icon is required** in order to make the pass work. Omitting an icon resolution, might make a pass work on a device (e.g. Mac) but not on another (e.g. iPhone). _Manifest.json_ and _signature_ will be automatically ignored from the model and generated in runtime.
98 |
99 | You can also create `.lproj` folders (e.g. _en.lproj_ or _it.lproj_) containing localized media. To include a folder or translate texts inside the pass, please refer to [Localizing Passes](https://github.com/alexandercerutti/passkit-generator/wiki/API-Documentation-Reference#localizing-passes) in the wiki API documentation.
100 |
101 | To include a file that belongs to an `.lproj` folder in buffers, you'll just have to name a key like `en.lproj/thumbnail.png`.
102 |
103 | ##### Pass.json
104 |
105 | Create a `pass.json` by taking example from examples folder models or the one provided by Apple for the [first tutorial](https://apple.co/2NA2nus) and fill it with the basic informations, that are `teamIdentifier`, `passTypeIdentifier` and all the other basic keys like pass type. Please refer to [Pass interface documentation on Apple Developers](https://apple.co/3DeKKYA).
106 |
107 | ```json
108 | {
109 | "formatVersion": 1,
110 | "passTypeIdentifier": "pass.",
111 | "teamIdentifier": "",
112 | "organizationName": "",
113 | "description": "A localizable description of your pass. To do so, put here a placeholder.",
114 | "boardingPass": {}
115 | }
116 | ```
117 |
118 |
119 |
120 | ##### Certificates
121 |
122 | The third step is about the developer and WWDR certificates. I suggest you to create a certificate-dedicated folder inside your working directory (e.g. `./certs`) to contain everything concerning the certificates.
123 |
124 | This is a standard procedure: you would have to do it also without using this library. We'll use OpenSSL to complete our work (or to do it entirely, if only on terminal), so be sure to have it installed.
125 |
126 | [Follow the **FULL GUIDE in wiki** to get all the files you need to proceed](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates).
127 |
128 | ---
129 |
130 |
131 |
132 | ## Usage Examples
133 |
134 | Importing:
135 |
136 | ```typescript
137 | /** CommonJS **/
138 | const { PKPass } = require("passkit-generator");
139 |
140 | /** ESM **/
141 | import { PKPass } from "passkit-generator";
142 | ```
143 |
144 | ### Folder Model
145 |
146 | ```typescript
147 | try {
148 | /** Each, but last, can be either a string or a Buffer. See API Documentation for more */
149 | const { wwdr, signerCert, signerKey, signerKeyPassphrase } = getCertificatesContentsSomehow();
150 |
151 | const pass = await PKPass.from({
152 | /**
153 | * Note: .pass extension is enforced when reading a
154 | * model from FS, even if not specified here below
155 | */
156 | model: "./passModels/myFirstModel.pass",
157 | certificates: {
158 | wwdr,
159 | signerCert,
160 | signerKey,
161 | signerKeyPassphrase
162 | },
163 | }, {
164 | // keys to be added or overridden
165 | serialNumber: "AAGH44625236dddaffbda"
166 | });
167 |
168 | // Adding some settings to be written inside pass.json
169 | pass.localize("en", { ... });
170 | pass.setBarcodes("36478105430"); // Random value
171 |
172 | // Generate the stream .pkpass file stream
173 | const stream = pass.getAsStream();
174 | doSomethingWithTheStream(stream);
175 |
176 | // or
177 |
178 | const buffer = pass.getAsBuffer();
179 | doSomethingWithTheBuffer(buffer);
180 | } catch (err) {
181 | doSomethingWithTheError(err);
182 | }
183 | ```
184 |
185 | ### Buffer Model
186 |
187 | ```typescript
188 | try {
189 | /** Each, but last, can be either a string or a Buffer. See API Documentation for more */
190 | const { wwdr, signerCert, signerKey, signerKeyPassphrase } = getCertificatesContentsSomehow();
191 |
192 | const pass = new PKPass({
193 | "thumbnail.png": Buffer.from([ ... ]),
194 | "icon.png": Buffer.from([ ... ]),
195 | "pass.json": Buffer.from([ ... ]),
196 | "it.lproj/pass.strings": Buffer.from([ ... ])
197 | },
198 | {
199 | wwdr,
200 | signerCert,
201 | signerKey,
202 | signerKeyPassphrase,
203 | },
204 | {
205 | // keys to be added or overridden
206 | serialNumber: "AAGH44625236dddaffbda",
207 | });
208 |
209 | // Adding some settings to be written inside pass.json
210 | pass.localize("en", { ... });
211 | pass.setBarcodes("36478105430"); // Random value
212 |
213 | // Generate the stream .pkpass file stream
214 | const stream = pass.getAsStream();
215 | doSomethingWithTheStream(stream);
216 |
217 | // or
218 |
219 | const buffer = pass.getAsBuffer();
220 | doSomethingWithTheBuffer(buffer);
221 | } catch (err) {
222 | doSomethingWithTheError(err);
223 | }
224 |
225 | ```
226 |
227 | For more complex usage examples, please refer to [examples](https://github.com/alexandercerutti/passkit-generator/tree/master/examples) folder.
228 |
229 | ---
230 |
231 | ## Other
232 |
233 | If you used this package in any of your projects, feel free to open a topic in issues to tell me and include a project description or link (for companies). 😊 You'll make me feel like my time hasn't been wasted, even if it had not anyway because I learnt and keep learning a lot of things by creating this.
234 |
235 | The idea to develop this package, was born during the Apple Developer Academy 17/18, in Naples, Italy, driven by the need to create an iOS app component regarding passes generation for events.
236 |
237 | A big thanks to all the people and friends in the Apple Developer Academy (and not) that pushed me and helped me into realizing something like this and a big thanks to the ones that helped me to make technical choices and to all the contributors.
238 |
239 | Any contribution, is welcome.
240 | Made with ❤️ in Italy.
241 |
242 | ---
243 |
244 | ## Contributors
245 |
246 | A big thanks to all the people that contributed to improve this package. Any contribution is welcome. Do you have an idea to make this improve or something to say? Open a topic in the issues and we'll discuss together! Thank you ❤️
247 | Also a big big big big thank you to all the financial contributors, which help me maintain the development of this package ❤️!
248 |
249 | ### Code Contributors
250 |
251 |
252 |
253 | ### Financial Contributors
254 |
255 | Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/passkit-generator/contribute)]
256 |
257 | #### Individuals
258 |
259 |
260 |
261 | #### Organizations
262 |
263 | Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/passkit-generator/contribute)]
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
--------------------------------------------------------------------------------
/assets/logo-dark.afdesign:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/assets/logo-dark.afdesign
--------------------------------------------------------------------------------
/examples/cloudflare-worker/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 |
3 | logs
4 | _.log
5 | npm-debug.log_
6 | yarn-debug.log*
7 | yarn-error.log*
8 | lerna-debug.log*
9 | .pnpm-debug.log*
10 |
11 | # Diagnostic reports (https://nodejs.org/api/report.html)
12 |
13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
14 |
15 | # Runtime data
16 |
17 | pids
18 | _.pid
19 | _.seed
20 | \*.pid.lock
21 |
22 | # Directory for instrumented libs generated by jscoverage/JSCover
23 |
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 |
28 | coverage
29 | \*.lcov
30 |
31 | # nyc test coverage
32 |
33 | .nyc_output
34 |
35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
36 |
37 | .grunt
38 |
39 | # Bower dependency directory (https://bower.io/)
40 |
41 | bower_components
42 |
43 | # node-waf configuration
44 |
45 | .lock-wscript
46 |
47 | # Compiled binary addons (https://nodejs.org/api/addons.html)
48 |
49 | build/Release
50 |
51 | # Dependency directories
52 |
53 | node_modules/
54 | jspm_packages/
55 |
56 | # Snowpack dependency directory (https://snowpack.dev/)
57 |
58 | web_modules/
59 |
60 | # TypeScript cache
61 |
62 | \*.tsbuildinfo
63 |
64 | # Optional npm cache directory
65 |
66 | .npm
67 |
68 | # Optional eslint cache
69 |
70 | .eslintcache
71 |
72 | # Optional stylelint cache
73 |
74 | .stylelintcache
75 |
76 | # Microbundle cache
77 |
78 | .rpt2_cache/
79 | .rts2_cache_cjs/
80 | .rts2_cache_es/
81 | .rts2_cache_umd/
82 |
83 | # Optional REPL history
84 |
85 | .node_repl_history
86 |
87 | # Output of 'npm pack'
88 |
89 | \*.tgz
90 |
91 | # Yarn Integrity file
92 |
93 | .yarn-integrity
94 |
95 | # dotenv environment variable files
96 |
97 | .env
98 | .env.development.local
99 | .env.test.local
100 | .env.production.local
101 | .env.local
102 |
103 | # parcel-bundler cache (https://parceljs.org/)
104 |
105 | .cache
106 | .parcel-cache
107 |
108 | # Next.js build output
109 |
110 | .next
111 | out
112 |
113 | # Nuxt.js build / generate output
114 |
115 | .nuxt
116 | dist
117 |
118 | # Gatsby files
119 |
120 | .cache/
121 |
122 | # Comment in the public line in if your project uses Gatsby and not Next.js
123 |
124 | # https://nextjs.org/blog/next-9-1#public-directory-support
125 |
126 | # public
127 |
128 | # vuepress build output
129 |
130 | .vuepress/dist
131 |
132 | # vuepress v2.x temp and cache directory
133 |
134 | .temp
135 | .cache
136 |
137 | # Docusaurus cache and generated files
138 |
139 | .docusaurus
140 |
141 | # Serverless directories
142 |
143 | .serverless/
144 |
145 | # FuseBox cache
146 |
147 | .fusebox/
148 |
149 | # DynamoDB Local files
150 |
151 | .dynamodb/
152 |
153 | # TernJS port file
154 |
155 | .tern-port
156 |
157 | # Stores VSCode versions used for testing VSCode extensions
158 |
159 | .vscode-test
160 |
161 | # yarn v2
162 |
163 | .yarn/cache
164 | .yarn/unplugged
165 | .yarn/build-state.yml
166 | .yarn/install-state.gz
167 | .pnp.\*
168 |
169 | # wrangler project
170 |
171 | .dev.vars
172 | .wrangler/
173 |
--------------------------------------------------------------------------------
/examples/cloudflare-worker/README.md:
--------------------------------------------------------------------------------
1 | # Cloudflare Workers example (wrangler)
2 |
3 | This is a sample project for showing passkit-generator working on a Cloudflare Worker.
4 |
5 | Cloudflare Workers are serverless function based on Browser's V8 (instead of Node). For this reason Cloudflare workers are need to setup to support node compatibility (see wranger.toml).
6 |
7 | This example offers just the generation of a single static `boardingPass`.
8 |
9 | > Please note that creating and publishing a Cloudflare Workers with passkit-generator, might require you to buy a plan.
10 | > Cloudflare limits are pretty low.
11 |
12 | ## Setting up
13 |
14 | Install the dependencies from wherever path you are with `pnpm install`. Installing the dependencies will link passkit-generator in the parent workspace, so to reflect any change, it will be enough to build passkit-generator and restart the example.
15 |
16 | Configure wrangler and your account [according to the guide](https://developers.cloudflare.com/workers/get-started/guide).
17 | You are always suggested to start with a brand new project and to not clone this one, so that you won't miss any configuration you might need.
18 |
19 | ### Secrets and certificates
20 |
21 | This example uses some environmental variables (secrets), which can be set through Wrangler CLI, through Dashboard or through `wrangler.toml`, as per [envs documentation](https://developers.cloudflare.com/workers/platform/environment-variables#adding-secrets-via-wrangler) and [secrets documentation](https://developers.cloudflare.com/workers/configuration/secrets/):
22 |
23 | - `SIGNER_CERT`
24 | - `SIGNER_KEY`
25 | - `SIGNER_PASSPHRASE`
26 | - `WWDR`
27 |
28 | So, assuming you have `certificates` folder in the root of passkit-generator and all the dependencies installed, you'll be able to directly inject your secrets into wrangler by doing this.
29 |
30 | ```sh
31 | $ cat ../../../certificates/signerKey.pem | pnpm wrangler secret put SIGNER_KEY
32 | ```
33 |
34 | These variables are exposed on `env` when performing the request.
35 |
36 | For the sake of the example, `signerCert`, `signerKey`, `signerKeyPassphrase` and `wwdr` are set to be distributed through `wrangler.toml`, but you should keep them safe in the secrets storage above.
37 |
38 | ### Running locally
39 |
40 | Install dependencies via `npm install`. Then, to run the worker locally, run `npm run example`.
41 |
42 | ### Example details
43 |
44 | Several details are described inside the `wrangler.toml` file. Give them a look.
45 |
--------------------------------------------------------------------------------
/examples/cloudflare-worker/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cloudflare-worker",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "clear:deps": "rm -rf node_modules",
8 | "example": "pnpm wrangler dev"
9 | },
10 | "dependencies": {
11 | "passkit-generator": "workspace:*"
12 | },
13 | "devDependencies": {
14 | "@cloudflare/workers-types": "^4.20250109.0",
15 | "typescript": "^5.7.3",
16 | "wrangler": "^3.101.0",
17 | "@types/node": "^20"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/cloudflare-worker/src/files.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.png" {
2 | const content: Buffer;
3 | export default content;
4 | }
5 |
--------------------------------------------------------------------------------
/examples/cloudflare-worker/src/index.ts:
--------------------------------------------------------------------------------
1 | import { PKPass } from "passkit-generator";
2 | import { Buffer } from "node:buffer";
3 |
4 | /** Assets are handled by Wrangler by specifying the rule inside wrangler.toml */
5 | import icon from "../../models/exampleBooking.pass/icon.png";
6 | import icon2x from "../../models/exampleBooking.pass/icon@2x.png";
7 | import footer from "../../models/exampleBooking.pass/footer.png";
8 | import footer2x from "../../models/exampleBooking.pass/footer@2x.png";
9 | import background2x from "../../models/examplePass.pass/background@2x.png";
10 |
11 | export interface Env {
12 | /**
13 | * "var" (instead of let and cost) is required here
14 | * to make typescript mark that these global variables
15 | * are available also in globalThis.
16 | *
17 | * These are secrets we have defined through `wrangler secret put `.
18 | * @see https://developers.cloudflare.com/workers/platform/environment-variables
19 | */
20 |
21 | WWDR: string;
22 | /** Pass signerCert */
23 | SIGNER_CERT: string;
24 | /** Pass signerKey */
25 | SIGNER_KEY: string;
26 | SIGNER_PASSPHRASE: string;
27 | }
28 |
29 | /**
30 | * Request entry point
31 | */
32 |
33 | export default {
34 | async fetch(
35 | request: Request,
36 | env: Env,
37 | ctx: ExecutionContext,
38 | ): Promise {
39 | return generatePass(env);
40 | },
41 | };
42 |
43 | async function generatePass(env: Env) {
44 | const pass = new PKPass(
45 | {
46 | "icon.png": Buffer.from(icon),
47 | "icon@2x.png": Buffer.from(icon2x),
48 | "footer.png": Buffer.from(footer),
49 | "footer@2x.png": Buffer.from(footer2x),
50 | "background@2x.png": Buffer.from(background2x),
51 | },
52 | {
53 | signerCert: env.SIGNER_CERT,
54 | signerKey: env.SIGNER_KEY,
55 | signerKeyPassphrase: env.SIGNER_PASSPHRASE,
56 | wwdr: env.WWDR,
57 | },
58 | {
59 | description: "Example Pass generated through a cloudflare worker",
60 | serialNumber: "81592CQ7838",
61 | passTypeIdentifier: "pass.com.passkitgenerator",
62 | teamIdentifier: "F53WB8AE67",
63 | organizationName: "Apple Inc.",
64 | foregroundColor: "rgb(255, 255, 255)",
65 | backgroundColor: "rgb(60, 65, 76)",
66 | },
67 | );
68 |
69 | pass.setBarcodes("1276451828321");
70 | pass.type = "boardingPass";
71 | pass.transitType = "PKTransitTypeAir";
72 |
73 | pass.headerFields.push(
74 | {
75 | key: "header1",
76 | label: "Data",
77 | value: "25 mag",
78 | textAlignment: "PKTextAlignmentCenter",
79 | },
80 | {
81 | key: "header2",
82 | label: "Volo",
83 | value: "EZY997",
84 | textAlignment: "PKTextAlignmentCenter",
85 | },
86 | );
87 |
88 | pass.primaryFields.push(
89 | {
90 | key: "IATA-source",
91 | value: "NAP",
92 | label: "Napoli",
93 | textAlignment: "PKTextAlignmentLeft",
94 | },
95 | {
96 | key: "IATA-destination",
97 | value: "VCE",
98 | label: "Venezia Marco Polo",
99 | textAlignment: "PKTextAlignmentRight",
100 | },
101 | );
102 |
103 | pass.secondaryFields.push(
104 | {
105 | key: "secondary1",
106 | label: "Imbarco chiuso",
107 | value: "18:40",
108 | textAlignment: "PKTextAlignmentCenter",
109 | },
110 | {
111 | key: "sec2",
112 | label: "Partenze",
113 | value: "19:10",
114 | textAlignment: "PKTextAlignmentCenter",
115 | },
116 | {
117 | key: "sec3",
118 | label: "SB",
119 | value: "Sì",
120 | textAlignment: "PKTextAlignmentCenter",
121 | },
122 | {
123 | key: "sec4",
124 | label: "Imbarco",
125 | value: "Anteriore",
126 | textAlignment: "PKTextAlignmentCenter",
127 | },
128 | );
129 |
130 | pass.auxiliaryFields.push(
131 | {
132 | key: "aux1",
133 | label: "Passeggero",
134 | value: "MR. WHO KNOWS",
135 | textAlignment: "PKTextAlignmentLeft",
136 | },
137 | {
138 | key: "aux2",
139 | label: "Posto",
140 | value: "1A*",
141 | textAlignment: "PKTextAlignmentCenter",
142 | },
143 | );
144 |
145 | pass.backFields.push(
146 | {
147 | key: "document number",
148 | label: "Numero documento:",
149 | value: "- -",
150 | textAlignment: "PKTextAlignmentLeft",
151 | },
152 | {
153 | key: "You're checked in, what next",
154 | label: "Hai effettuato il check-in, Quali sono le prospettive",
155 | value: "",
156 | textAlignment: "PKTextAlignmentLeft",
157 | },
158 | {
159 | key: "Check In",
160 | label: "1. check-in✓",
161 | value: "",
162 | textAlignment: "PKTextAlignmentLeft",
163 | },
164 | {
165 | key: "checkIn",
166 | label: "",
167 | value: "Le uscite d'imbarco chiudono 30 minuti prima della partenza, quindi sii puntuale. In questo aeroporto puoi utilizzare la corsia Fast Track ai varchi di sicurezza.",
168 | textAlignment: "PKTextAlignmentLeft",
169 | },
170 | {
171 | key: "2. Bags",
172 | label: "2. Bagaglio",
173 | value: "",
174 | textAlignment: "PKTextAlignmentLeft",
175 | },
176 | {
177 | key: "Require special assistance",
178 | label: "Assistenza speciale",
179 | value: "Se hai richiesto assistenza speciale, presentati a un membro del personale nell'area di Consegna bagagli almeno 90 minuti prima del volo.",
180 | textAlignment: "PKTextAlignmentLeft",
181 | },
182 | {
183 | key: "3. Departures",
184 | label: "3. Partenze",
185 | value: "",
186 | textAlignment: "PKTextAlignmentLeft",
187 | },
188 | {
189 | key: "photoId",
190 | label: "Un documento d’identità corredato di fotografia",
191 | value: "è obbligatorio su TUTTI i voli. Per un viaggio internazionale è necessario un passaporto valido o, dove consentita, una carta d’identità.",
192 | textAlignment: "PKTextAlignmentLeft",
193 | },
194 | {
195 | key: "yourSeat",
196 | label: "Il tuo posto:",
197 | value: "verifica il tuo numero di posto nella parte superiore. Durante l’imbarco utilizza le scale anteriori e posteriori: per le file 1-10 imbarcati dalla parte anteriore; per le file 11-31 imbarcati dalla parte posteriore. Colloca le borse di dimensioni ridotte sotto il sedile davanti a te.",
198 | textAlignment: "PKTextAlignmentLeft",
199 | },
200 | {
201 | key: "Pack safely",
202 | label: "Bagaglio sicuro",
203 | value: "Fai clic http://easyjet.com/it/articoli-pericolosi per maggiori informazioni sulle merci pericolose oppure visita il sito CAA http://www.caa.co.uk/default.aspx?catid=2200",
204 | textAlignment: "PKTextAlignmentLeft",
205 | },
206 | {
207 | key: "Thank you for travelling easyJet",
208 | label: "Grazie per aver viaggiato con easyJet",
209 | value: "",
210 | textAlignment: "PKTextAlignmentLeft",
211 | },
212 | );
213 |
214 | return new Response(pass.getAsBuffer(), {
215 | headers: {
216 | "Content-type": pass.mimeType,
217 | "Content-disposition": `attachment; filename=myPass.pkpass`,
218 | },
219 | });
220 | }
221 |
--------------------------------------------------------------------------------
/examples/cloudflare-worker/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2021",
4 | "lib": ["es2021"],
5 | "jsx": "react",
6 | "module": "Node16",
7 | "moduleResolution": "Node16",
8 | "types": ["@cloudflare/workers-types", "node"],
9 | "resolveJsonModule": true,
10 | "allowJs": true,
11 | "checkJs": false,
12 | "noEmit": true,
13 | "isolatedModules": true,
14 | "allowSyntheticDefaultImports": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "strict": true,
17 | "skipLibCheck": true
18 | },
19 | "include": ["src/**/*"]
20 | }
21 |
--------------------------------------------------------------------------------
/examples/cloudflare-worker/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "pg-cw-example"
2 | main = "src/index.ts"
3 |
4 | #########################################################################################
5 | # This must be enabled to make passkit-generator compatible with cloudflare workers #
6 | #########################################################################################
7 |
8 | compatibility_flags = [ "nodejs_compat" ]
9 | compatibility_date = "2024-09-23"
10 |
11 | ###################################################################
12 | ### This is needed to import `.png` files with esm imports. ###
13 | ###################################################################
14 |
15 | rules = [
16 | { type = "Data", globs = ["**/*.png"], fallthrough = true }
17 | ]
18 |
19 | ########################################################################################################
20 | # These are some envs. These should actually be secrets, but for the sake of the #
21 | # example, we are going to put these here. #
22 | # #
23 | # Remember to postfix every certificate line with "\", for TOML requirement for multiline strings. #
24 | # See: https://toml.io/en/ #
25 | # #
26 | # E.g. #
27 | # #
28 | # WWDR = """ \ #
29 | # -----BEGIN CERTIFICATE----- \ #
30 | # MIIEVTCCAz2gAwIBAgIUE9x3lVJx5T3GMujM/+Uh88zFztIwDQYJKoZIhvcNAQEL \ #
31 | # ... #
32 | ########################################################################################################
33 |
34 | [vars]
35 | WWDR = """ \
36 |
37 |
38 |
39 | """
40 |
41 | SIGNER_CERT = """ \
42 |
43 |
44 |
45 | """
46 |
47 | SIGNER_KEY = """ \
48 |
49 |
50 |
51 | """
52 |
53 | SIGNER_PASSPHRASE = ""
54 |
55 |
56 |
57 |
58 | # DEFAULTS explanations
59 |
60 |
61 |
62 | # Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
63 | # Note: Use secrets to store sensitive data.
64 | # Docs: https://developers.cloudflare.com/workers/platform/environment-variables
65 | # [vars]
66 | # MY_VARIABLE = "production_value"
67 |
--------------------------------------------------------------------------------
/examples/firebase/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": ""
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/examples/firebase/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | firebase-debug.log*
8 | firebase-debug.*.log*
9 |
10 | # Firebase cache
11 | .firebase/
12 |
13 | # Firebase config
14 |
15 | # Uncomment this if you'd like others to create their own Firebase project.
16 | # For a team working on the same Firebase project(s), it is recommended to leave
17 | # it commented so all members can deploy to the same project(s) in .firebaserc.
18 | # .firebaserc
19 |
20 | # Runtime data
21 | pids
22 | *.pid
23 | *.seed
24 | *.pid.lock
25 |
26 | # Directory for instrumented libs generated by jscoverage/JSCover
27 | lib-cov
28 |
29 | # Coverage directory used by tools like istanbul
30 | coverage
31 |
32 | # nyc test coverage
33 | .nyc_output
34 |
35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
36 | .grunt
37 |
38 | # Bower dependency directory (https://bower.io/)
39 | bower_components
40 |
41 | # node-waf configuration
42 | .lock-wscript
43 |
44 | # Compiled binary addons (http://nodejs.org/api/addons.html)
45 | build/Release
46 |
47 | # Dependency directories
48 | node_modules/
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Optional REPL history
57 | .node_repl_history
58 |
59 | # Output of 'npm pack'
60 | *.tgz
61 |
62 | # Yarn Integrity file
63 | .yarn-integrity
64 |
--------------------------------------------------------------------------------
/examples/firebase/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "functions": [
3 | {
4 | "source": "functions",
5 | "codebase": "default",
6 | "ignore": [
7 | "node_modules",
8 | ".git",
9 | "firebase-debug.log",
10 | "firebase-debug.*.log"
11 | ],
12 | "predeploy": [
13 | "cp -r ../models functions/models",
14 | "npm --prefix \"$RESOURCE_DIR\" run build"
15 | ],
16 | "postdeploy": ["rm -rf functions/models"]
17 | }
18 | ],
19 | "storage": {
20 | "rules": "storage.rules"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/examples/firebase/functions/.env:
--------------------------------------------------------------------------------
1 | # Please note that these could be considered like
2 | # secrets, so using an .env file might not be the
3 | # best solution.
4 | #
5 | # It is fine for the purpose of demostrating how
6 | # a firebase cloud functions is created and
7 | # deployed when using passkit-generator
8 | #
9 | #
10 | # For production you might want to check how to
11 | # deploy secrets.
12 | #
13 | # Check: https://firebase.google.com/docs/functions/config-env?gen=2nd#secret-manager
14 |
15 | #TODO change this with the content of your WWDR certificate
16 | WWDR=""
17 |
18 | #TODO add inside quotes the content of your signer certificate
19 | SIGNER_CERT=""
20 |
21 | #TODO add inside quotes the content of your signer key certificate
22 | SIGNER_KEY=""
23 |
24 | #TODO change this with the passphrase of your SIGNER_KEY
25 | SIGNER_KEY_PASSPHRASE=123456
--------------------------------------------------------------------------------
/examples/firebase/functions/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled JavaScript files
2 | lib/**/*.js
3 | lib/**/*.js.map
4 |
5 | # TypeScript v1 declaration files
6 | typings/
7 |
8 | # Node.js dependency directory
9 | node_modules/
10 |
--------------------------------------------------------------------------------
/examples/firebase/functions/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "functions",
3 | "description": "Cloud Functions for Firebase",
4 | "scripts": {
5 | "serve": "pnpm build && pnpm firebase emulators:start --only functions",
6 | "shell": "pnpm build && pnpm firebase functions:shell",
7 | "deploy": "pnpm firebase deploy --only functions",
8 | "logs": "pnpm firebase functions:log",
9 | "clear:deps": "rm -rf node_modules",
10 | "build": "rm -rf lib && pnpm tsc"
11 | },
12 | "engines": {
13 | "node": "20"
14 | },
15 | "type": "module",
16 | "main": "lib/index.js",
17 | "dependencies": {
18 | "firebase-admin": "^13.0.2",
19 | "firebase-functions": "^6.2.0",
20 | "tslib": "^2.8.1",
21 | "passkit-generator": "workspace:*"
22 | },
23 | "peerDependencies": {
24 | "passkit-generator": "workspace:*"
25 | },
26 | "devDependencies": {
27 | "firebase-functions-test": "^3.4.0",
28 | "firebase-tools": "^13.29.1",
29 | "typescript": "^5.7.3",
30 | "@types/node": "^20"
31 | },
32 | "private": true
33 | }
34 |
--------------------------------------------------------------------------------
/examples/firebase/functions/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as functions from "firebase-functions/https";
2 | import { initializeApp } from "firebase-admin/app";
3 | import { getStorage } from "firebase-admin/storage";
4 | import { PKPass } from "passkit-generator";
5 | import type { Barcode, TransitType } from "passkit-generator";
6 | import fs from "node:fs";
7 | import path from "node:path";
8 | import os from "node:os";
9 |
10 | // Please note this is experimental in NodeJS as
11 | // it is marked as Stage 3 in TC39
12 | // Should probably not be used in production
13 | import startData from "./startData.json" assert { type: "json" };
14 |
15 | interface RequestWithBody extends functions.Request {
16 | body: {
17 | passModel: string;
18 | serialNumber: string;
19 | logoText: string;
20 | textColor: string;
21 | backgroundColor: string;
22 | labelColor: string;
23 | stripColor: string;
24 | relevantDate?: string;
25 | expiryDate?: string;
26 | relevantLocationLat?: number;
27 | relevantLocationLong?: number;
28 | header?: { value: string; label: string }[];
29 | primary?: { value: string; label: string }[];
30 | secondary?: { value: string; label: string }[];
31 | auxiliary?: { value: string; label: string }[];
32 | codeAlt?: string;
33 | qrText?: string;
34 | transitType?: TransitType;
35 | codeType?: Barcode["format"];
36 | thumbnailFile?: string;
37 | logoFile?: string;
38 | };
39 | }
40 |
41 | /**
42 | * Declaring our .env contents
43 | * @see https://firebase.google.com/docs/functions/config-env?gen=2nd#deploying_multiple_sets_of_environment_variables
44 | */
45 |
46 | declare global {
47 | namespace NodeJS {
48 | interface ProcessEnv {
49 | WWDR: string;
50 | SIGNER_CERT: string;
51 | SIGNER_KEY: string;
52 | SIGNER_KEY_PASSPHRASE: string;
53 |
54 | // reserved, but we use it to discriminate emulator from deploy
55 | FUNCTIONS_EMULATOR: "true" | "false" | undefined;
56 | }
57 | }
58 | }
59 |
60 | /**
61 | * @see https://firebase.google.com/docs/storage/admin/start#node.js
62 | */
63 |
64 | initializeApp({
65 | storageBucket: startData.FIREBASE_BUCKET_ADDR,
66 | });
67 |
68 | const storageRef = getStorage().bucket();
69 |
70 | export const pass = functions.onRequest(
71 | async (request: RequestWithBody, response) => {
72 | let modelBasePath: string;
73 |
74 | if (process.env.FUNCTIONS_EMULATOR === "true") {
75 | modelBasePath = "../../models/";
76 | } else {
77 | /**
78 | * Models are cloned on deploy through
79 | * the commands in `firebase.json` and
80 | * are uploaded along with our program.
81 | *
82 | * When deployed, root folder is the `functions` folder
83 | */
84 | modelBasePath = "./models/";
85 | }
86 |
87 | try {
88 | if (request.headers["content-type"] !== "application/json") {
89 | response.status(400);
90 | response.send({
91 | error: `Payload with content-type ${request.headers["content-type"]} is not supported. Use "application/json"`,
92 | });
93 | return;
94 | }
95 |
96 | if (!request.body.passModel) {
97 | response.status(400);
98 | response.send({
99 | error: "Unspecified 'passModel' parameter: which model should be used?",
100 | });
101 |
102 | return;
103 | }
104 |
105 | if (request.body.passModel.endsWith(".pass")) {
106 | request.body.passModel = request.body.passModel.replace(
107 | ".pass",
108 | "",
109 | );
110 | }
111 |
112 | const newPass = await PKPass.from(
113 | {
114 | /**
115 | * Get relevant pass model from model folder (see passkit-generator/examples/models/)
116 | * Path seems to get read like the function is in "firebase/" folder and not in "firebase/functions/"
117 | */
118 | model: `${modelBasePath}${request.body.passModel}.pass`,
119 | certificates: {
120 | // Assigning certificates from certs folder (you will need to provide these yourself)
121 | wwdr: process.env.WWDR,
122 | signerCert: process.env.SIGNER_CERT,
123 | signerKey: process.env.SIGNER_KEY,
124 | signerKeyPassphrase: process.env.SIGNER_KEY_PASSPHRASE,
125 | },
126 | },
127 | {
128 | serialNumber: request.body.serialNumber,
129 | description: "DESCRIPTION",
130 | logoText: request.body.logoText,
131 | foregroundColor: request.body.textColor,
132 | backgroundColor: request.body.backgroundColor,
133 | labelColor: request.body.labelColor,
134 | },
135 | );
136 |
137 | if (newPass.type == "boardingPass") {
138 | if (!request.body.transitType) {
139 | response.status(400);
140 | response.send({
141 | error: "transitType is required",
142 | });
143 |
144 | return;
145 | }
146 |
147 | newPass.transitType = request.body.transitType;
148 | }
149 |
150 | if (typeof request.body.relevantDate === "string") {
151 | newPass.setRelevantDate(new Date(request.body.relevantDate));
152 | }
153 |
154 | if (typeof request.body.expiryDate === "string") {
155 | newPass.setExpirationDate(new Date(request.body.expiryDate));
156 | }
157 |
158 | if (
159 | request.body.relevantLocationLat &&
160 | request.body.relevantLocationLong
161 | ) {
162 | newPass.setLocations({
163 | latitude: request.body.relevantLocationLat,
164 | longitude: request.body.relevantLocationLong,
165 | });
166 | }
167 |
168 | if (Array.isArray(request.body.header)) {
169 | for (let i = 0; i < request.body.header.length; i++) {
170 | const field = request.body.header[i];
171 |
172 | if (!(field?.label && field.value)) {
173 | continue;
174 | }
175 |
176 | newPass.headerFields.push({
177 | key: `header${i}`,
178 | label: field.label,
179 | value: field.value,
180 | });
181 | }
182 | }
183 |
184 | if (Array.isArray(request.body.primary)) {
185 | for (let i = 0; i < request.body.primary.length; i++) {
186 | const field = request.body.primary[i];
187 |
188 | if (!(field?.label && field.value)) {
189 | continue;
190 | }
191 |
192 | newPass.primaryFields.push({
193 | key: `primary${i}`,
194 | label: field.label,
195 | value:
196 | newPass.type == "boardingPass"
197 | ? field.value.toUpperCase()
198 | : field.value,
199 | });
200 | }
201 | }
202 |
203 | if (Array.isArray(request.body.secondary)) {
204 | for (let i = 0; i < request.body.secondary.length; i++) {
205 | const field = request.body.secondary[i];
206 |
207 | if (!(field?.label && field.value)) {
208 | continue;
209 | }
210 |
211 | const isElementInLastTwoPositions =
212 | i === request.body.secondary.length - 2 ||
213 | i === request.body.secondary.length - 1;
214 |
215 | newPass.secondaryFields.push({
216 | key: `secondary${i}`,
217 | label: field.label,
218 | value: field.value,
219 | textAlignment: isElementInLastTwoPositions
220 | ? "PKTextAlignmentRight"
221 | : "PKTextAlignmentLeft",
222 | });
223 | }
224 | }
225 |
226 | if (Array.isArray(request.body.auxiliary)) {
227 | for (let i = 0; i < request.body.auxiliary.length; i++) {
228 | const field = request.body.auxiliary[i];
229 |
230 | if (!(field?.label && field.value)) {
231 | continue;
232 | }
233 |
234 | const isElementInLastTwoPositions =
235 | i === request.body.auxiliary.length - 2 ||
236 | i === request.body.auxiliary.length - 1;
237 |
238 | newPass.auxiliaryFields.push({
239 | key: `auxiliary${i}`,
240 | label: field.label,
241 | value: field.value,
242 | textAlignment: isElementInLastTwoPositions
243 | ? "PKTextAlignmentRight"
244 | : "PKTextAlignmentLeft",
245 | });
246 | }
247 | }
248 |
249 | if (request.body.qrText && request.body.codeType) {
250 | newPass.setBarcodes({
251 | message: request.body.qrText,
252 | format: request.body.codeType,
253 | messageEncoding: "iso-8859-1",
254 | altText: request.body.codeAlt?.trim() ?? "",
255 | });
256 | }
257 |
258 | const { thumbnailFile, logoFile } = request.body;
259 |
260 | // Downloading thumbnail and logo files from Firebase Storage and adding to pass
261 | if (newPass.type == "generic" || newPass.type == "eventTicket") {
262 | if (thumbnailFile) {
263 | const tempPath1 = path.join(os.tmpdir(), thumbnailFile);
264 |
265 | try {
266 | await storageRef
267 | .file(`thumbnails/${thumbnailFile}`)
268 | .download({ destination: tempPath1 });
269 |
270 | const buffer = fs.readFileSync(tempPath1);
271 |
272 | newPass.addBuffer("thumbnail.png", buffer);
273 | newPass.addBuffer("thumbnail@2x.png", buffer);
274 | } catch (error) {
275 | console.error(error);
276 | }
277 | }
278 | }
279 |
280 | if (logoFile) {
281 | const tempPath2 = path.join(os.tmpdir(), logoFile);
282 |
283 | try {
284 | await storageRef
285 | .file(`logos/${logoFile}`)
286 | .download({ destination: tempPath2 });
287 |
288 | const buffer = fs.readFileSync(tempPath2);
289 |
290 | newPass.addBuffer("logo.png", buffer);
291 | newPass.addBuffer("logo@2x.png", buffer);
292 | } catch (error) {
293 | console.error(error);
294 | }
295 | }
296 |
297 | const bufferData = newPass.getAsBuffer();
298 |
299 | response.set("Content-Type", newPass.mimeType);
300 | response.status(200).send(bufferData);
301 | } catch (error) {
302 | console.log("Error Uploading pass " + error);
303 |
304 | const err = Object.assign(
305 | {},
306 | ...Object.entries(Object.getOwnPropertyDescriptors(error)).map(
307 | ([key, descriptor]) => {
308 | return { [key]: descriptor.value };
309 | },
310 | ),
311 | );
312 |
313 | response.status(500);
314 | response.send({
315 | error: err,
316 | });
317 | }
318 | },
319 | );
320 |
--------------------------------------------------------------------------------
/examples/firebase/functions/src/startData.json:
--------------------------------------------------------------------------------
1 | {
2 | "FIREBASE_BUCKET_ADDR": "",
4 | "passTypeIdentifier": "pass.com.passkitgenerator",
5 | "teamIdentifier": "F53WB8AE67",
6 | "description": "Apple Event",
7 | "organizationName": "Apple Inc.",
8 | "foregroundColor": "#ffffff",
9 | "backgroundColor": "#1595d7",
10 | "labelColor": "#ffffff",
11 | "voided": false,
12 | "sharingProhibited": false,
13 | "beacons": [
14 | {
15 | "proximityUUID": "B7FA1C44-B5B2-436D-AD33-C26651C498BB",
16 | "relevantText": "Prepare to check in."
17 | }
18 | ],
19 | "locations": [{ "latitude": 37.3349, "longitude": -122.00902 }],
20 | "barcode": {
21 | "message": "",
22 | "format": "PKBarcodeFormatQR",
23 | "messageEncoding": "iso-8859-1",
24 | "altText": "Special Apple Event"
25 | },
26 | "nfc": {
27 | "message": "",
28 | "encryptionPublicKey": "MDkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDIgADrCFAKUbtUh7oAnm5drhZKj5+CHO+B5Q0VdF4QTkd17I="
29 | },
30 | "eventTicket": {
31 | "headerFields": [
32 | { "key": "eventDateTime", "label": "DATE", "value": "Sep 09, 2024" }
33 | ],
34 | "primaryFields": [
35 | { "key": "eventGuest", "label": "Guest", "value": "" }
36 | ],
37 | "secondaryFields": [
38 | {
39 | "key": "eventVenue",
40 | "label": "Venue",
41 | "value": "Steve Jobs Theater"
42 | }
43 | ],
44 | "backFields": [
45 | {
46 | "key": "eventBackInformation",
47 | "label": "Event Information",
48 | "value": "A government-issued photo ID will be required at check-in.\n\nPasses are unique, non-transferable, and can only be used once.\n\nYour confirmation email contains details regarding arrival and event location.\n\nPhoto & Video Release:\nPhotographs, audio and/or video taken at the event by Apple, or others on behalf of Apple, may include your voice, image or likeness. You agree that Apple may use such photographs, audio and/or video, including those that may contain your voice, image or likeness, for any purpose on a worldwide basis in perpetuity, without any compensation to you, and you release Apple from all liability related thereto."
49 | },
50 | {
51 | "key": "eventBackLocation",
52 | "label": "EVENT LOCATION",
53 | "value": "Steve Jobs Theater\n10600 N. Tantau Avenue\nCupertino, CA 95014"
54 | }
55 | ]
56 | },
57 | "suppressStripShine": false,
58 | "passLastModDate": "2024-08-21T00:00:00.000Z",
59 | "semantics": {
60 | "eventType": "PKEventTypeGeneric",
61 | "eventName": "Special Apple Event",
62 | "venueName": "Steve Jobs Theater",
63 | "entranceDescription": "Apple Park Visitor Center",
64 | "attendeeName": "",
65 | "eventStartDate": "2024-10-04T17:00:00+00:00",
66 | "eventEndDate": "2024-10-05T19:00:00+00:00",
67 | "venueLocation": { "latitude": 37.3349, "longitude": -122.00902 },
68 | "playlistIDs": []
69 | },
70 | "relevantDates": [
71 | {
72 | "startDate": "2024-10-03T17:00:00+00:00",
73 | "endDate": "2024-10-03T23:00:00+00:00"
74 | },
75 | {
76 | "startDate": "2024-10-04T17:00:00+00:00",
77 | "endDate": "2024-10-04T19:00:00+00:00"
78 | }
79 | ],
80 | "directionsInformationURL": "https://apple.co/mapsapvc",
81 | "contactVenueEmail": "rsvp_events@apple.com",
82 | "preferredStyleSchemes": ["posterEventTicket", "eventTicket"]
83 | }
84 |
--------------------------------------------------------------------------------
/examples/models/glowTime.pass/secondaryLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/glowTime.pass/secondaryLogo.png
--------------------------------------------------------------------------------
/examples/models/glowTime.pass/secondaryLogo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/glowTime.pass/secondaryLogo@2x.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithAdmissionLevel.pass/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithAdmissionLevel.pass/background.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithAdmissionLevel.pass/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithAdmissionLevel.pass/icon.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithAdmissionLevel.pass/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithAdmissionLevel.pass/logo.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithAdmissionLevel.pass/pass.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "passTypeIdentifier": "pass.com.passkitgenerator",
4 | "teamIdentifier": "F53WB8AE67",
5 | "groupingIdentifier": "ticket-demo",
6 | "description": "Description",
7 | "organizationName": "Something",
8 | "backgroundColor": "rgb(255, 255, 255)",
9 | "foregroundColor": "rgb(0, 0, 0)",
10 | "labelColor": "rgb(0, 0, 0)",
11 | "logoText": "iOS18 EventTicket Demo",
12 | "preferredStyleSchemes": ["posterEventTicket", "eventTicket"],
13 | "eventTicket": {
14 | "headerFields": [
15 | {
16 | "key": "event_date",
17 | "label": "event-date",
18 | "value": "26.09.2024"
19 | }
20 | ]
21 | },
22 | "semantics": {
23 | "venueParkingLotsOpenDate": "2024-10-04T09:00:00+00:00",
24 | "venueGatesOpenDate": "2024-10-06T20:00:00+00:00",
25 | "eventType": "PKEventTypeLivePerformance",
26 | "eventName": "Secret meeting place",
27 | "admissionLevel": "VIP Access",
28 | "venueRegionName": "Undisclosed location",
29 | "performerNames": ["Lady Gaga"],
30 | "venueBoxOfficeOpenDate": "2024-10-06T20:15:00+00:00",
31 | "venueCloseDate": "2024-10-06T23:59:59+00:00",
32 | "venueDoorsOpenDate": "2024-10-06T20:00:00+00:00",
33 | "venueFanZoneOpenDate": "2024-10-06T19:30:00+00:00",
34 | "updatedEventStartDate": "2024-10-06T21:30:00+00:00",
35 | "updatedEventEndDate": "2024-10-07T01:30:00+00:00",
36 | "admissionLevelAbbreviation": "VIP A.",
37 | "venueEntranceDoor": "15A",
38 | "venueEntrancePortal": "7B",
39 | "albumIDs": ["1440818588"],
40 | "additionalTicketAttributes": "3,4,5",
41 | "entranceDescription": "Event at The Stadium",
42 | "venueLocation": {
43 | "latitude": 51.555557,
44 | "longitude": 0.238041
45 | }
46 | },
47 | "parkingInformationURL": "https://www.southbayjazzfestival.com/parking",
48 | "directionsInformationURL": "https://apple.co/mapsapvc",
49 | "contactVenueEmail": "rsvp_events@apple.com",
50 | "relevantDates": [
51 | {
52 | "startDate": "2024-10-03T17:00:00+01:00",
53 | "endDate": "2024-10-03T23:00:00+01:00"
54 | },
55 | {
56 | "startDate": "2024-10-06T00:00:00+00:00",
57 | "endDate": "2024-10-06T19:00:00+01:00"
58 | }
59 | ],
60 | "nfc": {
61 | "message": "message",
62 | "encryptionPublicKey": "MDkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDIgADwKMBv29ByaSLiGF0FctuyB+Hs2oZ1kDIYhTVllPexNE="
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithAdmissionLevel.pass/secondaryLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithAdmissionLevel.pass/secondaryLogo.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithNewSeats.pass/artwork.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithNewSeats.pass/artwork.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithNewSeats.pass/artwork@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithNewSeats.pass/artwork@3x.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithNewSeats.pass/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithNewSeats.pass/icon.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithNewSeats.pass/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithNewSeats.pass/logo.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithNewSeats.pass/pass.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "passTypeIdentifier": "pass.com.passkitgenerator",
4 | "teamIdentifier": "F53WB8AE67",
5 | "groupingIdentifier": "ticket-demo",
6 | "description": "Description",
7 | "organizationName": "A some kind of event happening tomorrow",
8 | "backgroundColor": "#ffffff",
9 | "foregroundColor": "#000000",
10 | "labelColor": "#FF0000",
11 | "logoText": "Demo",
12 | "preferredStyleSchemes": ["posterEventTicket", "eventTicket"],
13 | "eventTicket": {
14 | "headerFields": [
15 | {
16 | "key": "event_date",
17 | "label": "event-date",
18 | "value": "26.09.2024"
19 | }
20 | ],
21 | "primaryFields": [
22 | { "key": "event_name", "label": "event-name", "value": "Dune" }
23 | ],
24 | "additionalInfoFields": [
25 | {
26 | "key": "additionalInfo-1",
27 | "label": "Additional Info 1",
28 | "value": "The text to show"
29 | },
30 | {
31 | "key": "additionalInfo-2",
32 | "label": "Additional Info 2",
33 | "value": "The text to show 2"
34 | },
35 | {
36 | "key": "lineItem3",
37 | "label": "Emergency Contact",
38 | "value": "+1 8716 12736131",
39 | "dataDetectorTypes": ["PKDataDetectorTypePhoneNumber"]
40 | },
41 | {
42 | "key": "lineItem4",
43 | "label": "Test link",
44 | "value": "https://apple.com",
45 | "dataDetectorTypes": ["PKDataDetectorTypeLink"],
46 | "attributedValue": "Used literally on iPhone, used correctly on Watch"
47 | }
48 | ]
49 | },
50 | "semantics": {
51 | "venueParkingLotsOpenDate": "2024-10-09T04:00:00+00:00",
52 | "venueGatesOpenDate": "2024-10-09T06:00:00+00:00",
53 | "eventLiveMessage": "This event is going to start soon! Try to relax your anus (cit.)",
54 | "eventType": "PKEventTypeLivePerformance",
55 | "eventName": "South Bay Jazz Festival",
56 | "entranceDescription": "Event at The Stadium",
57 | "venueLocation": {
58 | "latitude": 51.555557,
59 | "longitude": 0.238041
60 | },
61 | "venueName": "The Stadium",
62 | "performerNames": ["Lady Gaga"],
63 | "eventStartDate": "2024-10-08T22:00:00+00:00",
64 | "eventEndDate": "2024-10-09T23:59:59+00:00",
65 | "tailgatingAllowed": true,
66 | "seats": [
67 | {
68 | "seatNumber": "5",
69 | "seatRow": "3",
70 | "seatSection": "100",
71 | "seatSectionColor": "#FFD700"
72 | }
73 | ],
74 | "artistIDs": ["984117861"]
75 | },
76 | "directionsInformationURL": "https://www.displaysomeinfoexample.com",
77 | "contactVenueWebsite": "https://www.venueexample.com",
78 | "relevantDates": [
79 | {
80 | "startDate": "2024-10-09T17:00:00+01:00",
81 | "endDate": "2024-10-09T23:59:59+01:00"
82 | },
83 | {
84 | "startDate": "2024-10-10T17:00:00+00:00",
85 | "endDate": "2024-10-10T19:00:00+00:00"
86 | },
87 | {
88 | "startDate": "2024-10-11T17:00:00+00:00",
89 | "endDate": "2024-10-11T19:00:00+00:00"
90 | }
91 | ],
92 | "nfc": {
93 | "message": "message",
94 | "encryptionPublicKey": "MDkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDIgADwKMBv29ByaSLiGF0FctuyB+Hs2oZ1kDIYhTVllPexNE="
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithNewSeats.pass/strip@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithNewSeats.pass/strip@3x.png
--------------------------------------------------------------------------------
/examples/models/posterEventTicketWithNewSeats.pass/venueMap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexandercerutti/passkit-generator/63e5a77c83febe7a573f1f1a774e42a67d56c008/examples/models/posterEventTicketWithNewSeats.pass/venueMap.png
--------------------------------------------------------------------------------
/examples/self-hosted/README.md:
--------------------------------------------------------------------------------
1 | # Examples
2 |
3 | This is examples folder. These examples are used to test new features and as sample showcases.
4 |
5 | Each example owns an endpoint where a pass can be reached. This project is built upon Express.js.
6 |
7 | Typescript compilation is done automatically through `tsx`.
8 |
9 | Before generating a new pass, you'll have to override the `passTypeIdentifier` and `teamIdentifier` for them to match the data in your certificates. This can be done in two ways:
10 |
11 | a) Edit manually the `pass.json` of the model you are going to run;
12 | b) Pass the two fields in the query string of the example you are running when querying it;
13 |
14 | Omitting this step, will make your pass unopenable.
15 |
16 | Install the dependencies from wherever path you are with `pnpm install`. Installing the dependencies will link passkit-generator in the parent workspace, so to reflect any change, it will be enough to build passkit-generator and restart the example.
17 |
18 | Then be sure to be placed in this folder (`examples/self-hosted`) and run this command to run the web server:
19 |
20 | ```sh
21 | $ pnpm example;
22 | ```
23 |
24 | Certificates paths in examples are linked to a folder `certificates` in the root of this project which is not provided.
25 | To make them work, you'll have to edit both certificates and model path.
26 |
27 | Every example runs on `0.0.0.0:8080`. Visit `http://localhost:8080/:example/:modelName`, by replacing `:example` with one of the following and `:modelName` with one inside models folder.
28 |
29 | Please note that `field.js` example is hardcoded to download `exampleBooking.pass`.
30 |
31 | | Example name | Endpoint name | Additional notes |
32 | | -------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
33 | | localize | `/localize` | - |
34 | | fields | `/fields` | - |
35 | | expirationDate | `/expirationDate` | Accepts a required parameter in query string `fn`, which can be either `expiration` or `void`, to switch generated example. |
36 | | scratch | `/scratch` | - |
37 | | PKPass.from | `/pkpassfrom` | - |
38 | | barcodes | `/barcodes` | Using `?alt=true` query parameter, will lead to barcode string message usage instead of selected ones |
39 | | pkpasses | `/pkpasses` | - |
40 |
41 | ---
42 |
43 | Every contribution is really appreciated. ❤️ Thank you!
44 |
--------------------------------------------------------------------------------
/examples/self-hosted/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "examples-self-hosted",
3 | "version": "0.0.0",
4 | "private": true,
5 | "description": "Passkit-generator self-hosted examples",
6 | "author": "Alexander P. Cerutti ",
7 | "license": "ISC",
8 | "type": "module",
9 | "scripts": {
10 | "clear:deps": "rm -rf node_modules",
11 | "example": "pnpm tsx src/index.ts",
12 | "example:debug": "pnpm tsx --inspect-brk src/index.ts"
13 | },
14 | "peerDependencies": {
15 | "passkit-generator": "workspace:*"
16 | },
17 | "dependencies": {
18 | "express": "^5.0.1",
19 | "node-fetch": "^3.2.3",
20 | "passkit-generator": "workspace:*",
21 | "tslib": "^2.7.0"
22 | },
23 | "devDependencies": {
24 | "@types/express": "5.0.0",
25 | "@types/express-serve-static-core": "^5.0.4",
26 | "tsx": "^4.19.2",
27 | "typescript": "^5.7.3",
28 | "@types/node": "^20.0.0"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/PKPass.from.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * PKPass.from static method example.
3 | * Here it is showed manual model reading and
4 | * creating through another PKPass because in the other
5 | * examples, creation through templates is already shown
6 | */
7 |
8 | import path from "node:path";
9 | import { fileURLToPath } from "node:url";
10 | import { promises as fs } from "node:fs";
11 | import { PKPass } from "passkit-generator";
12 | import { app } from "./webserver.js";
13 | import { getCertificates } from "./shared.js";
14 |
15 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
16 |
17 | // ******************************************** //
18 | // *** CODE FROM GET MODEL FOLDER INTERNALS *** //
19 | // ******************************************** //
20 |
21 | /**
22 | * Removes hidden files from a list (those starting with dot)
23 | *
24 | * @params from - list of file names
25 | * @return
26 | */
27 |
28 | export function removeHidden(from: Array): Array {
29 | return from.filter((e) => e.charAt(0) !== ".");
30 | }
31 |
32 | async function readFileOrDirectory(filePath: string) {
33 | if ((await fs.lstat(filePath)).isDirectory()) {
34 | return Promise.all(await readDirectory(filePath));
35 | }
36 |
37 | const fileBuffer = await fs.readFile(filePath);
38 |
39 | return getObjectFromModelFile(filePath, fileBuffer, 1);
40 | }
41 |
42 | /**
43 | * Returns an object containing the parsed fileName
44 | * from a path along with its content.
45 | *
46 | * @param filePath
47 | * @param content
48 | * @param depthFromEnd - used to preserve localization lproj content
49 | * @returns
50 | */
51 |
52 | function getObjectFromModelFile(
53 | filePath: string,
54 | content: Buffer,
55 | depthFromEnd: number,
56 | ) {
57 | const fileComponents = filePath.split(path.sep);
58 | const fileName = fileComponents
59 | .slice(fileComponents.length - depthFromEnd)
60 | .join("/");
61 |
62 | return { [fileName]: content };
63 | }
64 |
65 | /**
66 | * Reads a directory and returns all the files in it
67 | * as an Array
68 | *
69 | * @param filePath
70 | * @returns
71 | */
72 |
73 | async function readDirectory(filePath: string) {
74 | const dirContent = await fs.readdir(filePath).then(removeHidden);
75 |
76 | return dirContent.map(async (fileName) => {
77 | const content = await fs.readFile(path.resolve(filePath, fileName));
78 | return getObjectFromModelFile(
79 | path.resolve(filePath, fileName),
80 | content,
81 | 2,
82 | );
83 | });
84 | }
85 |
86 | // *************************** //
87 | // *** EXAMPLE FROM NOW ON *** //
88 | // *************************** //
89 |
90 | const passTemplate = new Promise(async (resolve) => {
91 | const modelPath = path.resolve(__dirname, `../../models/examplePass.pass`);
92 | const [modelFilesList, certificates] = await Promise.all([
93 | fs.readdir(modelPath),
94 | getCertificates(),
95 | ]);
96 |
97 | const modelRecords = (
98 | await Promise.all(
99 | /**
100 | * Obtaining flattened array of buffer records
101 | * containing file name and the buffer itself.
102 | *
103 | * This goes also to read every nested l10n
104 | * subfolder.
105 | */
106 |
107 | modelFilesList.map((fileOrDirectoryPath) => {
108 | const fullPath = path.resolve(modelPath, fileOrDirectoryPath);
109 |
110 | return readFileOrDirectory(fullPath);
111 | }),
112 | )
113 | )
114 | .flat(1)
115 | .reduce((acc, current) => ({ ...acc, ...current }), {});
116 |
117 | /** Creating a PKPass Template */
118 |
119 | return resolve(
120 | new PKPass(modelRecords, {
121 | wwdr: certificates.wwdr,
122 | signerCert: certificates.signerCert,
123 | signerKey: certificates.signerKey,
124 | signerKeyPassphrase: certificates.signerKeyPassphrase,
125 | }),
126 | );
127 | });
128 |
129 | app.route("/pkpassfrom/:modelName").get(async (request, response) => {
130 | const passName =
131 | request.params.modelName +
132 | "_" +
133 | new Date().toISOString().split("T")[0].replace(/-/gi, "");
134 |
135 | const templatePass = await passTemplate;
136 |
137 | try {
138 | const pass = await PKPass.from(
139 | templatePass,
140 | request.body || request.params || request.query,
141 | );
142 |
143 | if (pass.type === "boardingPass" && !pass.transitType) {
144 | // Just to not make crash the creation if we use a boardingPass
145 | pass.transitType = "PKTransitTypeAir";
146 | }
147 |
148 | const stream = pass.getAsStream();
149 |
150 | response.set({
151 | "Content-type": pass.mimeType,
152 | "Content-disposition": `attachment; filename=${passName}.pkpass`,
153 | });
154 |
155 | stream.pipe(response);
156 | } catch (err) {
157 | console.log(err);
158 |
159 | response.set({
160 | "Content-type": "text/html",
161 | });
162 |
163 | response.send(err.message);
164 | }
165 | });
166 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/PKPasses.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * PKPasses generation through PKPass.pack static method
3 | * example.
4 | * Here it is showed manual model reading and
5 | * creating through another PKPass because in the other
6 | * examples, creation through templates is already shown
7 | *
8 | * PLEASE NOTE THAT, AT TIME OF WRITING, THIS EXAMPLE WORKS
9 | * ONLY IF PASSES ARE DOWNLOADED FROM SAFARI, due to the
10 | * support of PKPasses archives. To test this, you might
11 | * need to open a tunnel through NGROK if you cannot access
12 | * to your local machine (in my personal case, developing
13 | * under WSL is a pretty big limitation sometimes).
14 | *
15 | * PLEASE ALSO NOTE that, AT TIME OF WRITING (iOS 15.0 - 15.2)
16 | * Pass Viewer suffers of a really curious bug: issuing several
17 | * passes within the same pkpasses archive, all with the same
18 | * serialNumber, will lead to have a broken view and to add
19 | * just one pass. You can see the screenshots below:
20 | *
21 | * https://imgur.com/bDTbcDg.jpg
22 | * https://imgur.com/Y4GpuHT.jpg
23 | * https://i.imgur.com/qbJMy1d.jpg
24 | *
25 | * - "Alberto, come to look at APPLE."
26 | * **Alberto looks**
27 | * - "MAMMA MIA!""
28 | *
29 | * A feedback to Apple have been sent for this.
30 | */
31 |
32 | import { app } from "./webserver.js";
33 | import { getCertificates } from "./shared.js";
34 | import { promises as fs } from "node:fs";
35 | import path from "node:path";
36 | import { PKPass } from "passkit-generator";
37 |
38 | // *************************** //
39 | // *** EXAMPLE FROM NOW ON *** //
40 | // *************************** //
41 |
42 | function getRandomColorPart() {
43 | return Math.floor(Math.random() * 255);
44 | }
45 |
46 | async function generatePass(props: Object) {
47 | const [iconFromModel, certificates] = await Promise.all([
48 | fs.readFile(
49 | path.resolve(
50 | __dirname,
51 | "../../models/exampleBooking.pass/icon.png",
52 | ),
53 | ),
54 | getCertificates(),
55 | ]);
56 |
57 | const pass = new PKPass(
58 | {},
59 | {
60 | wwdr: certificates.wwdr,
61 | signerCert: certificates.signerCert,
62 | signerKey: certificates.signerKey,
63 | signerKeyPassphrase: certificates.signerKeyPassphrase,
64 | },
65 | {
66 | ...props,
67 | description: "Example Apple Wallet Pass",
68 | passTypeIdentifier: "pass.com.passkitgenerator",
69 | // Be sure to issue different serialNumbers or you might incur into the bug explained above
70 | serialNumber: `nmyuxofgna${Math.random()}`,
71 | organizationName: `Test Organization ${Math.random()}`,
72 | teamIdentifier: "F53WB8AE67",
73 | foregroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
74 | labelColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
75 | backgroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
76 | },
77 | );
78 |
79 | pass.type = "boardingPass";
80 | pass.transitType = "PKTransitTypeAir";
81 |
82 | pass.setBarcodes({
83 | message: "123456789",
84 | format: "PKBarcodeFormatQR",
85 | });
86 |
87 | pass.headerFields.push(
88 | {
89 | key: "header-field-test-1",
90 | value: "Unknown",
91 | },
92 | {
93 | key: "header-field-test-2",
94 | value: "unknown",
95 | },
96 | );
97 |
98 | pass.primaryFields.push(
99 | {
100 | key: "primaryField-1",
101 | value: "NAP",
102 | },
103 | {
104 | key: "primaryField-2",
105 | value: "VCE",
106 | },
107 | );
108 |
109 | /**
110 | * Required by Apple. If one is not available, a
111 | * pass might be openable on a Mac but not on a
112 | * specific iPhone model
113 | */
114 |
115 | pass.addBuffer("icon.png", iconFromModel);
116 | pass.addBuffer("icon@2x.png", iconFromModel);
117 | pass.addBuffer("icon@3x.png", iconFromModel);
118 |
119 | return pass;
120 | }
121 |
122 | app.route("/pkpasses/:modelName").get(async (request, response) => {
123 | const passName =
124 | request.params.modelName +
125 | "_" +
126 | new Date().toISOString().split("T")[0].replace(/-/gi, "");
127 |
128 | try {
129 | const passes = await Promise.all([
130 | generatePass(request.body || request.params || request.query),
131 | generatePass(request.body || request.params || request.query),
132 | generatePass(request.body || request.params || request.query),
133 | generatePass(request.body || request.params || request.query),
134 | ]);
135 |
136 | const pkpasses = PKPass.pack(...passes);
137 |
138 | response.set({
139 | "Content-type": pkpasses.mimeType,
140 | "Content-disposition": `attachment; filename=${passName}.pkpasses`,
141 | });
142 |
143 | const stream = pkpasses.getAsStream();
144 |
145 | stream.pipe(response);
146 | } catch (err) {
147 | console.log(err);
148 |
149 | response.set({
150 | "Content-type": "text/html",
151 | });
152 |
153 | response.send(err.message);
154 | }
155 | });
156 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/fields.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Fields pushing dimostration
3 | * To see all the included Fields, just open the pass
4 | * Refer to https://apple.co/2Nvshvn to see how passes
5 | * have their fields disposed.
6 | *
7 | * In this example we are going to imitate an EasyJet boarding pass
8 | *
9 | * @Author: Alexander P. Cerutti
10 | */
11 |
12 | import path from "node:path";
13 | import { PKPass } from "passkit-generator";
14 | import { app } from "./webserver.js";
15 | import { getCertificates } from "./shared.js";
16 |
17 | app.route("/fields/:modelName").get(async (request, response) => {
18 | const passName =
19 | "exampleBooking" +
20 | "_" +
21 | new Date().toISOString().split("T")[0].replace(/-/gi, "");
22 |
23 | const certificates = await getCertificates();
24 |
25 | try {
26 | const pass = await PKPass.from(
27 | {
28 | model: path.resolve(__dirname, "../../models/exampleBooking"),
29 | certificates: {
30 | wwdr: certificates.wwdr,
31 | signerCert: certificates.signerCert,
32 | signerKey: certificates.signerKey,
33 | signerKeyPassphrase: certificates.signerKeyPassphrase,
34 | },
35 | },
36 | request.body || request.params || request.query,
37 | );
38 |
39 | pass.transitType = "PKTransitTypeAir";
40 |
41 | pass.headerFields.push(
42 | {
43 | key: "header1",
44 | label: "Data",
45 | value: "25 mag",
46 | textAlignment: "PKTextAlignmentCenter",
47 | },
48 | {
49 | key: "header2",
50 | label: "Volo",
51 | value: "EZY997",
52 | textAlignment: "PKTextAlignmentCenter",
53 | },
54 | );
55 |
56 | pass.primaryFields.push(
57 | {
58 | key: "IATA-source",
59 | value: "NAP",
60 | label: "Napoli",
61 | textAlignment: "PKTextAlignmentLeft",
62 | },
63 | {
64 | key: "IATA-destination",
65 | value: "VCE",
66 | label: "Venezia Marco Polo",
67 | textAlignment: "PKTextAlignmentRight",
68 | },
69 | );
70 |
71 | pass.secondaryFields.push(
72 | {
73 | key: "secondary1",
74 | label: "Imbarco chiuso",
75 | value: "18:40",
76 | textAlignment: "PKTextAlignmentCenter",
77 | },
78 | {
79 | key: "sec2",
80 | label: "Partenze",
81 | value: "19:10",
82 | textAlignment: "PKTextAlignmentCenter",
83 | },
84 | {
85 | key: "sec3",
86 | label: "SB",
87 | value: "Sì",
88 | textAlignment: "PKTextAlignmentCenter",
89 | },
90 | {
91 | key: "sec4",
92 | label: "Imbarco",
93 | value: "Anteriore",
94 | textAlignment: "PKTextAlignmentCenter",
95 | },
96 | );
97 |
98 | pass.auxiliaryFields.push(
99 | {
100 | key: "aux1",
101 | label: "Passeggero",
102 | value: "MR. WHO KNOWS",
103 | textAlignment: "PKTextAlignmentLeft",
104 | },
105 | {
106 | key: "aux2",
107 | label: "Posto",
108 | value: "1A*",
109 | textAlignment: "PKTextAlignmentCenter",
110 | },
111 | );
112 |
113 | pass.backFields.push(
114 | {
115 | key: "document number",
116 | label: "Numero documento:",
117 | value: "- -",
118 | textAlignment: "PKTextAlignmentLeft",
119 | },
120 | {
121 | key: "You're checked in, what next",
122 | label: "Hai effettuato il check-in, Quali sono le prospettive",
123 | value: "",
124 | textAlignment: "PKTextAlignmentLeft",
125 | },
126 | {
127 | key: "Check In",
128 | label: "1. check-in✓",
129 | value: "",
130 | textAlignment: "PKTextAlignmentLeft",
131 | },
132 | {
133 | key: "checkIn",
134 | label: "",
135 | value: "Le uscite d'imbarco chiudono 30 minuti prima della partenza, quindi sii puntuale. In questo aeroporto puoi utilizzare la corsia Fast Track ai varchi di sicurezza.",
136 | textAlignment: "PKTextAlignmentLeft",
137 | },
138 | {
139 | key: "2. Bags",
140 | label: "2. Bagaglio",
141 | value: "",
142 | textAlignment: "PKTextAlignmentLeft",
143 | },
144 | {
145 | key: "Require special assistance",
146 | label: "Assistenza speciale",
147 | value: "Se hai richiesto assistenza speciale, presentati a un membro del personale nell'area di Consegna bagagli almeno 90 minuti prima del volo.",
148 | textAlignment: "PKTextAlignmentLeft",
149 | },
150 | {
151 | key: "3. Departures",
152 | label: "3. Partenze",
153 | value: "",
154 | textAlignment: "PKTextAlignmentLeft",
155 | },
156 | {
157 | key: "photoId",
158 | label: "Un documento d’identità corredato di fotografia",
159 | value: "è obbligatorio su TUTTI i voli. Per un viaggio internazionale è necessario un passaporto valido o, dove consentita, una carta d’identità.",
160 | textAlignment: "PKTextAlignmentLeft",
161 | },
162 | {
163 | key: "yourSeat",
164 | label: "Il tuo posto:",
165 | value: "verifica il tuo numero di posto nella parte superiore. Durante l’imbarco utilizza le scale anteriori e posteriori: per le file 1-10 imbarcati dalla parte anteriore; per le file 11-31 imbarcati dalla parte posteriore. Colloca le borse di dimensioni ridotte sotto il sedile davanti a te.",
166 | textAlignment: "PKTextAlignmentLeft",
167 | },
168 | {
169 | key: "Pack safely",
170 | label: "Bagaglio sicuro",
171 | value: "Fai clic http://easyjet.com/it/articoli-pericolosi per maggiori informazioni sulle merci pericolose oppure visita il sito CAA http://www.caa.co.uk/default.aspx?catid=2200",
172 | textAlignment: "PKTextAlignmentLeft",
173 | },
174 | {
175 | key: "Thank you for travelling easyJet",
176 | label: "Grazie per aver viaggiato con easyJet",
177 | value: "",
178 | textAlignment: "PKTextAlignmentLeft",
179 | },
180 | );
181 |
182 | const stream = pass.getAsStream();
183 |
184 | response.set({
185 | "Content-type": pass.mimeType,
186 | "Content-disposition": `attachment; filename=${passName}.pkpass`,
187 | });
188 |
189 | stream.pipe(response);
190 | } catch (err) {
191 | console.log(err);
192 |
193 | response.set({
194 | "Content-type": "text/html",
195 | });
196 |
197 | response.send(err.message);
198 | }
199 | });
200 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/index.ts:
--------------------------------------------------------------------------------
1 | import "./fields.js";
2 | import "./localize.js";
3 | import "./PKPass.from.js";
4 | import "./PKPasses.js";
5 | import "./scratch.js";
6 | import "./setBarcodes.js";
7 | import "./setExpirationDate.js";
8 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/localize.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * .localize() methods example
3 | * To see all the included languages, you have to unzip the
4 | * .pkpass file and check for .lproj folders
5 | */
6 |
7 | import path from "node:path";
8 | import { PKPass } from "passkit-generator";
9 | import { app } from "./webserver.js";
10 | import { getCertificates } from "./shared.js";
11 |
12 | app.route("/localize/:modelName").get(async (request, response) => {
13 | const passName =
14 | request.params.modelName +
15 | "_" +
16 | new Date().toISOString().split("T")[0].replace(/-/gi, "");
17 |
18 | const certificates = await getCertificates();
19 |
20 | try {
21 | const pass = await PKPass.from(
22 | {
23 | model: path.resolve(
24 | __dirname,
25 | `../../models/${request.params.modelName}`,
26 | ),
27 | certificates: {
28 | wwdr: certificates.wwdr,
29 | signerCert: certificates.signerCert,
30 | signerKey: certificates.signerKey,
31 | signerKeyPassphrase: certificates.signerKeyPassphrase,
32 | },
33 | },
34 | request.body || request.params || request.query,
35 | );
36 |
37 | // Italian, already has an .lproj which gets included...
38 | pass.localize("it", {
39 | EVENT: "Evento",
40 | LOCATION: "Dove",
41 | });
42 |
43 | // ...while German doesn't, so it gets created
44 | pass.localize("de", {
45 | EVENT: "Ereignis",
46 | LOCATION: "Ort",
47 | });
48 |
49 | // This language does not exist but is still added as .lproj folder
50 | pass.localize("zu", {});
51 |
52 | console.log("Added languages", Object.keys(pass.languages).join(", "));
53 |
54 | if (pass.type === "boardingPass" && !pass.transitType) {
55 | // Just to not make crash the creation if we use a boardingPass
56 | pass.transitType = "PKTransitTypeAir";
57 | }
58 |
59 | const stream = pass.getAsStream();
60 |
61 | response.set({
62 | "Content-type": pass.mimeType,
63 | "Content-disposition": `attachment; filename=${passName}.pkpass`,
64 | });
65 |
66 | stream.pipe(response);
67 | } catch (err) {
68 | console.log(err);
69 |
70 | response.set({
71 | "Content-type": "text/html",
72 | });
73 |
74 | response.send(err.message);
75 | }
76 | });
77 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/scratch.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This examples shows how you can create a PKPass from scratch,
3 | * by adding files later and not adding pass.json
4 | */
5 |
6 | import path from "node:path";
7 | import { promises as fs } from "node:fs";
8 | import { PKPass } from "passkit-generator";
9 | import { app } from "./webserver.js";
10 | import { getCertificates } from "./shared.js";
11 |
12 | function getRandomColorPart() {
13 | return Math.floor(Math.random() * 255);
14 | }
15 |
16 | app.route("/scratch/:modelName").get(async (request, response) => {
17 | const passName =
18 | request.params.modelName +
19 | "_" +
20 | new Date().toISOString().split("T")[0].replace(/-/gi, "");
21 |
22 | const [iconFromModel, certificates] = await Promise.all([
23 | fs.readFile(
24 | path.resolve(
25 | __dirname,
26 | "../../models/exampleBooking.pass/icon.png",
27 | ),
28 | ),
29 | getCertificates(),
30 | ]);
31 |
32 | try {
33 | const pass = new PKPass(
34 | {},
35 | {
36 | wwdr: certificates.wwdr,
37 | signerCert: certificates.signerCert,
38 | signerKey: certificates.signerKey,
39 | signerKeyPassphrase: certificates.signerKeyPassphrase,
40 | },
41 | {
42 | ...(request.body || request.params || request.query),
43 | description: "Example Apple Wallet Pass",
44 | passTypeIdentifier: "pass.com.passkitgenerator",
45 | serialNumber: "nmyuxofgna",
46 | organizationName: `Test Organization ${Math.random()}`,
47 | teamIdentifier: "F53WB8AE67",
48 | foregroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
49 | labelColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
50 | backgroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
51 | },
52 | );
53 |
54 | pass.type = "boardingPass";
55 | pass.transitType = "PKTransitTypeAir";
56 |
57 | pass.headerFields.push(
58 | {
59 | key: "header-field-test-1",
60 | value: "Unknown",
61 | },
62 | {
63 | key: "header-field-test-2",
64 | value: "unknown",
65 | },
66 | );
67 |
68 | pass.primaryFields.push(
69 | {
70 | key: "primaryField-1",
71 | value: "NAP",
72 | },
73 | {
74 | key: "primaryField-2",
75 | value: "VCE",
76 | },
77 | );
78 |
79 | /**
80 | * Required by Apple. If one is not available, a
81 | * pass might be openable on a Mac but not on a
82 | * specific iPhone model
83 | */
84 |
85 | pass.addBuffer("icon.png", iconFromModel);
86 | pass.addBuffer("icon@2x.png", iconFromModel);
87 | pass.addBuffer("icon@3x.png", iconFromModel);
88 |
89 | const stream = pass.getAsStream();
90 |
91 | response.set({
92 | "Content-type": pass.mimeType,
93 | "Content-disposition": `attachment; filename=${passName}.pkpass`,
94 | });
95 |
96 | stream.pipe(response);
97 | } catch (err) {
98 | console.log(err);
99 |
100 | response.set({
101 | "Content-type": "text/html",
102 | });
103 |
104 | response.send(err.message);
105 | }
106 | });
107 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/setBarcodes.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * .barcodes() methods example
3 | * Here we set the barcode. To see all the results, you can
4 | * both unzip .pkpass file or check the properties before
5 | * generating the whole bundle
6 | *
7 | * Pass ?alt=true as querystring to test a barcode generate
8 | * by a string
9 | */
10 |
11 | import { PKPass } from "passkit-generator";
12 | import path from "node:path";
13 | import { app } from "./webserver.js";
14 | import { getCertificates } from "./shared.js";
15 |
16 | app.route("/barcodes/:modelName").get(async (request, response) => {
17 | const passName =
18 | request.params.modelName +
19 | "_" +
20 | new Date().toISOString().split("T")[0].replace(/-/gi, "");
21 |
22 | const certificates = await getCertificates();
23 |
24 | try {
25 | const pass = await PKPass.from(
26 | {
27 | model: path.resolve(
28 | __dirname,
29 | `../../models/${request.params.modelName}`,
30 | ),
31 | certificates: {
32 | wwdr: certificates.wwdr,
33 | signerCert: certificates.signerCert,
34 | signerKey: certificates.signerKey,
35 | signerKeyPassphrase: certificates.signerKeyPassphrase,
36 | },
37 | },
38 | request.body || request.params || request.query || {},
39 | );
40 |
41 | if (request.query.alt === "true") {
42 | // After this, pass.props["barcodes"] will have support for all the formats
43 | pass.setBarcodes("Thank you for using this package <3");
44 |
45 | console.log(
46 | "Barcodes support is autocompleted:",
47 | pass.props["barcodes"],
48 | );
49 | } else {
50 | // After this, pass.props["barcodes"] will have support for just two of three
51 | // of the passed format (the valid ones);
52 |
53 | pass.setBarcodes(
54 | {
55 | message: "Thank you for using this package <3",
56 | format: "PKBarcodeFormatCode128",
57 | },
58 | {
59 | message: "Thank you for using this package <3",
60 | format: "PKBarcodeFormatPDF417",
61 | },
62 | );
63 | }
64 |
65 | if (pass.type === "boardingPass" && !pass.transitType) {
66 | // Just to not make crash the creation if we use a boardingPass
67 | pass.transitType = "PKTransitTypeAir";
68 | }
69 |
70 | const stream = pass.getAsStream();
71 |
72 | response.set({
73 | "Content-type": pass.mimeType,
74 | "Content-disposition": `attachment; filename=${passName}.pkpass`,
75 | });
76 |
77 | stream.pipe(response);
78 | } catch (err) {
79 | console.log(err);
80 |
81 | response.set({
82 | "Content-type": "text/html",
83 | });
84 |
85 | response.send(err.message);
86 | }
87 | });
88 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/setExpirationDate.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * .expiration() method and voided prop example
3 | * To check if a ticket is void, look at the barcode;
4 | * If it is grayed, the ticket is voided. May not be showed on macOS.
5 | *
6 | * To check if a ticket has an expiration date, you'll
7 | * have to wait two minutes.
8 | */
9 |
10 | import path from "node:path";
11 | import { PKPass } from "passkit-generator";
12 | import { app } from "./webserver.js";
13 | import { getCertificates } from "./shared.js";
14 |
15 | app.route("/expirationDate/:modelName").get(async (request, response) => {
16 | if (!request.query.fn) {
17 | response.send(
18 | "Generate a voided pass.
Generate a pass with expiration date",
19 | );
20 | return;
21 | }
22 |
23 | const certificates = await getCertificates();
24 |
25 | const passName =
26 | request.params.modelName +
27 | "_" +
28 | new Date().toISOString().split("T")[0].replace(/-/gi, "");
29 |
30 | try {
31 | const pass = await PKPass.from(
32 | {
33 | model: path.resolve(
34 | __dirname,
35 | `../../models/${request.params.modelName}`,
36 | ),
37 | certificates: {
38 | wwdr: certificates.wwdr,
39 | signerCert: certificates.signerCert,
40 | signerKey: certificates.signerKey,
41 | signerKeyPassphrase: certificates.signerKeyPassphrase,
42 | },
43 | },
44 | Object.assign(
45 | {
46 | voided: request.query.fn === "void",
47 | },
48 | { ...(request.body || request.params || request.query || {}) },
49 | ),
50 | );
51 |
52 | if (request.query.fn === "expiration") {
53 | // 2 minutes later...
54 | const d = new Date();
55 | d.setMinutes(d.getMinutes() + 2);
56 |
57 | // setting the expiration
58 | pass.setExpirationDate(d);
59 | console.log(
60 | "EXPIRATION DATE EXPECTED:",
61 | pass.props["expirationDate"],
62 | );
63 | }
64 |
65 | if (pass.type === "boardingPass" && !pass.transitType) {
66 | // Just to not make crash the creation if we use a boardingPass
67 | pass.transitType = "PKTransitTypeAir";
68 | }
69 |
70 | const stream = pass.getAsStream();
71 |
72 | response.set({
73 | "Content-type": pass.mimeType,
74 | "Content-disposition": `attachment; filename=${passName}.pkpass`,
75 | });
76 |
77 | stream.pipe(response);
78 | } catch (err) {
79 | console.log(err);
80 |
81 | response.set({
82 | "Content-type": "text/html",
83 | });
84 |
85 | response.send(err.message);
86 | }
87 | });
88 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/shared.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs/promises";
2 | import path from "node:path";
3 | import { fileURLToPath } from "node:url";
4 |
5 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
6 |
7 | interface Cache {
8 | certificates:
9 | | {
10 | signerCert: Buffer | string;
11 | signerKey: Buffer | string;
12 | wwdr: Buffer | string;
13 | signerKeyPassphrase: string;
14 | }
15 | | undefined;
16 | }
17 |
18 | const cache: Cache = {
19 | certificates: undefined,
20 | };
21 |
22 | export async function getCertificates(): Promise<
23 | Exclude
24 | > {
25 | if (cache.certificates) {
26 | return cache.certificates;
27 | }
28 |
29 | const [signerCert, signerKey, wwdr, signerKeyPassphrase] =
30 | await Promise.all([
31 | fs.readFile(
32 | path.resolve(__dirname, "../../../certificates/signerCert.pem"),
33 | "utf-8",
34 | ),
35 | fs.readFile(
36 | path.resolve(__dirname, "../../../certificates/signerKey.pem"),
37 | "utf-8",
38 | ),
39 | fs.readFile(
40 | path.resolve(__dirname, "../../../certificates/WWDR.pem"),
41 | "utf-8",
42 | ),
43 | Promise.resolve("123456"),
44 | ]);
45 |
46 | cache.certificates = {
47 | signerCert,
48 | signerKey,
49 | wwdr,
50 | signerKeyPassphrase,
51 | };
52 |
53 | return cache.certificates;
54 | }
55 |
--------------------------------------------------------------------------------
/examples/self-hosted/src/webserver.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Generic webserver instance for the examples
3 | * @Author Alexander P. Cerutti
4 | * Requires express to run
5 | */
6 |
7 | import express from "express";
8 | export const app = express();
9 |
10 | app.use(express.json());
11 |
12 | app.listen(8080, "0.0.0.0", () => {
13 | console.log("Webserver started.");
14 | });
15 |
--------------------------------------------------------------------------------
/examples/self-hosted/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "outDir": "build",
6 | "sourceMap": true,
7 | "useUnknownInCatchVariables": false
8 | },
9 | "include": ["src/**/*"]
10 | }
11 |
--------------------------------------------------------------------------------
/examples/serverless/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 | jspm_packages
4 |
5 | # Serverless directories
6 | .serverless
7 | .build
8 | !*.js
9 |
--------------------------------------------------------------------------------
/examples/serverless/README.md:
--------------------------------------------------------------------------------
1 | # Serverless Examples
2 |
3 | This is a sample project for showing passkit-generator being used on cloud functions.
4 |
5 | Typescript compilation happens automatically through `serverless-plugin-typescript` when serverless is started.
6 |
7 | Before generating a new pass, you'll have to override the `passTypeIdentifier` and `teamIdentifier` for them to match the data in your certificates. This can be done in two ways:
8 |
9 | a) Edit manually the `pass.json` of the model you are going to run;
10 | b) Pass the two fields in the query string of the example you are running when querying it;
11 |
12 | Omitting this step, will make your pass unopenable.
13 |
14 | ## Configuration
15 |
16 | These examples are basically made for being executed locally. In the file `config.json`, some constants can be customized.
17 |
18 | ```json
19 | /** Passkit signerKey passphrase **/
20 | "SIGNER_KEY_PASSPHRASE": "123456",
21 |
22 | /** Bucket name where a pass is saved before being served. */
23 | "PASSES_S3_TEMP_BUCKET": "pkge-test",
24 |
25 | /** S3 Access key ID - "S3RVER" is default for `serverless-s3-local`. If this example is run offline, "S3RVER" will always be used. */
26 | "ACCESS_KEY_ID": "S3RVER",
27 |
28 | /** S3 Secret - "S3RVER" is default for `serverless-s3-local` */
29 | "SECRET_ACCESS_KEY": "S3RVER",
30 |
31 | /** Bucket that contains pass models **/
32 | "MODELS_S3_BUCKET": "pkge-mdbk"
33 | ```
34 |
35 | ## Run examples
36 |
37 | Install the dependencies from wherever path you are and run serverless. Installing the dependencies will link passkit-generator in the parent workspace, so to reflect any change, it will be enough to build passkit-generator and restart the example.
38 |
39 | ```sh
40 | $ pnpm install;
41 | $ pnpm example;
42 | ```
43 |
44 | This will start `serverless offline` with an additional host option (mainly for WSL environment).
45 | Serverless will start, by default, on `0.0.0.0:8080`.
46 |
47 | ### Available examples
48 |
49 | All the examples, except fields ones, require a `modelName` to be passed in queryString. The name will be checked against local FS or S3 bucket if example is deployed.
50 | Pass in queryString all the pass props you want to apply them to the final result.
51 |
52 | | Example name | Endpoint name | Additional notes |
53 | | -------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------- |
54 | | localize | `/localize` | - |
55 | | fields | `/fields` | - |
56 | | expirationDate | `/expirationDate` | - |
57 | | scratch | `/scratch` | - |
58 | | barcodes | `/barcodes` | Using `?alt=true` query parameter, will lead to barcode string message usage instead of selected ones |
59 | | pkpasses | `/pkpasses` | This example shows how to upload the pkpasses file on S3, even if it is discouraged. It has been done just to share the knowledge |
60 |
--------------------------------------------------------------------------------
/examples/serverless/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "SIGNER_KEY_PASSPHRASE": "123456",
3 | "PASSES_S3_TEMP_BUCKET": "pkge-test",
4 | "ACCESS_KEY_ID": "S3RVER",
5 | "SECRET_ACCESS_KEY": "S3RVER",
6 | "MODELS_S3_BUCKET": "pkge-mdbk"
7 | }
8 |
--------------------------------------------------------------------------------
/examples/serverless/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "examples-aws-lambda",
3 | "version": "0.0.0",
4 | "private": true,
5 | "description": "Passkit-generator examples for running in AWS Lambda",
6 | "author": "Alexander P. Cerutti ",
7 | "license": "ISC",
8 | "main": "src/index.js",
9 | "type": "module",
10 | "scripts": {
11 | "clear:deps": "rm -rf node_modules",
12 | "example": "pnpm serverless offline --host 0.0.0.0; :'specifying host due to WSL limits'"
13 | },
14 | "dependencies": {
15 | "aws-sdk": "^2.1692.0",
16 | "tslib": "^2.8.1",
17 | "passkit-generator": "workspace:*"
18 | },
19 | "devDependencies": {
20 | "@types/aws-lambda": "^8.10.147",
21 | "serverless-offline": "^8.8.1",
22 | "serverless-plugin-typescript": "^2.1.5",
23 | "serverless-s3-local": "^0.8.5",
24 | "typescript": "^5.7.3",
25 | "@types/node": "^20"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/examples/serverless/serverless.yml:
--------------------------------------------------------------------------------
1 | service: passkit-generator-test-lambda
2 | frameworkVersion: "3"
3 |
4 | plugins:
5 | - serverless-offline
6 | - serverless-plugin-typescript
7 | - serverless-s3-local
8 |
9 | provider:
10 | name: aws
11 | runtime: nodejs14.x
12 | lambdaHashingVersion: "20201221"
13 |
14 | functions:
15 | fields:
16 | handler: src/index.fields
17 | events:
18 | - httpApi:
19 | path: /fields
20 | method: get
21 | expiration:
22 | handler: src/index.expirationDate
23 | events:
24 | - httpApi:
25 | path: /expirationDate
26 | method: get
27 | localize:
28 | handler: src/index.localize
29 | events:
30 | - httpApi:
31 | path: /localize
32 | method: get
33 | barcodes:
34 | handler: src/index.barcodes
35 | events:
36 | - httpApi:
37 | path: /barcodes
38 | method: get
39 | scratch:
40 | handler: src/index.scratch
41 | events:
42 | - httpApi:
43 | path: /scratch
44 | method: get
45 | pkpasses:
46 | handler: src/index.pkpasses
47 | events:
48 | - httpApi:
49 | path: /pkpasses
50 | method: get
51 |
52 | custom:
53 | serverless-offline:
54 | httpPort: 8080
55 | s3:
56 | directory: /tmp
57 |
--------------------------------------------------------------------------------
/examples/serverless/src/functions/barcodes.ts:
--------------------------------------------------------------------------------
1 | import { ALBEvent, ALBResult } from "aws-lambda";
2 | import { PKPass } from "passkit-generator";
3 | import {
4 | throwClientErrorWithoutModelName,
5 | createPassGenerator,
6 | } from "../shared.js";
7 |
8 | /**
9 | * Lambda for barcodes example
10 | */
11 |
12 | export async function barcodes(event: ALBEvent) {
13 | try {
14 | throwClientErrorWithoutModelName(event);
15 | } catch (err) {
16 | return err;
17 | }
18 |
19 | const { modelName, alt, ...passOptions } = event.queryStringParameters;
20 |
21 | const passGenerator = createPassGenerator(modelName, passOptions);
22 |
23 | const pass = (await passGenerator.next()).value as unknown as PKPass;
24 |
25 | if (alt === "true") {
26 | // After this, pass.props["barcodes"] will have support for all the formats
27 | pass.setBarcodes("Thank you for using this package <3");
28 |
29 | console.log(
30 | "Barcodes support is autocompleted:",
31 | pass.props["barcodes"],
32 | );
33 | } else {
34 | // After this, pass.props["barcodes"] will have support for just two of three
35 | // of the passed format (the valid ones);
36 |
37 | pass.setBarcodes(
38 | {
39 | message: "Thank you for using this package <3",
40 | format: "PKBarcodeFormatCode128",
41 | },
42 | {
43 | message: "Thank you for using this package <3",
44 | format: "PKBarcodeFormatPDF417",
45 | },
46 | );
47 | }
48 |
49 | return (await passGenerator.next()).value as ALBResult;
50 | }
51 |
--------------------------------------------------------------------------------
/examples/serverless/src/functions/expirationDate.ts:
--------------------------------------------------------------------------------
1 | import { ALBEvent, ALBResult, Context } from "aws-lambda";
2 | import { PKPass } from "passkit-generator";
3 | import {
4 | throwClientErrorWithoutModelName,
5 | createPassGenerator,
6 | } from "../shared.js";
7 |
8 | /**
9 | * Lambda for expirationDate example
10 | */
11 |
12 | export async function expirationDate(event: ALBEvent, context: Context) {
13 | try {
14 | throwClientErrorWithoutModelName(event);
15 | } catch (err) {
16 | return err;
17 | }
18 |
19 | const { modelName, ...passOptions } = event.queryStringParameters;
20 |
21 | const passGenerator = createPassGenerator(modelName, passOptions);
22 |
23 | const pass = (await passGenerator.next()).value as PKPass;
24 |
25 | // 2 minutes later...
26 | const d = new Date();
27 | d.setMinutes(d.getMinutes() + 2);
28 |
29 | // setting the expiration
30 | (pass as PKPass).setExpirationDate(d);
31 | console.log(
32 | "EXPIRATION DATE EXPECTED:",
33 | (pass as PKPass).props["expirationDate"],
34 | );
35 |
36 | return (await passGenerator.next(pass as PKPass)).value as ALBResult;
37 | }
38 |
--------------------------------------------------------------------------------
/examples/serverless/src/functions/fields.ts:
--------------------------------------------------------------------------------
1 | import { ALBEvent, ALBResult } from "aws-lambda";
2 | import { PKPass } from "passkit-generator";
3 | import { createPassGenerator } from "../shared.js";
4 |
5 | /**
6 | * Lambda for fields example
7 | */
8 |
9 | export async function fields(event: ALBEvent) {
10 | const { modelName, ...passOptions } = event.queryStringParameters;
11 |
12 | const passGenerator = createPassGenerator("exampleBooking", passOptions);
13 |
14 | const pass = (await passGenerator.next()).value as PKPass;
15 |
16 | pass.transitType = "PKTransitTypeAir";
17 |
18 | pass.headerFields.push(
19 | {
20 | key: "header1",
21 | label: "Data",
22 | value: "25 mag",
23 | textAlignment: "PKTextAlignmentCenter",
24 | },
25 | {
26 | key: "header2",
27 | label: "Volo",
28 | value: "EZY997",
29 | textAlignment: "PKTextAlignmentCenter",
30 | },
31 | );
32 |
33 | pass.primaryFields.push(
34 | {
35 | key: "IATA-source",
36 | value: "NAP",
37 | label: "Napoli",
38 | textAlignment: "PKTextAlignmentLeft",
39 | },
40 | {
41 | key: "IATA-destination",
42 | value: "VCE",
43 | label: "Venezia Marco Polo",
44 | textAlignment: "PKTextAlignmentRight",
45 | },
46 | );
47 |
48 | pass.secondaryFields.push(
49 | {
50 | key: "secondary1",
51 | label: "Imbarco chiuso",
52 | value: "18:40",
53 | textAlignment: "PKTextAlignmentCenter",
54 | },
55 | {
56 | key: "sec2",
57 | label: "Partenze",
58 | value: "19:10",
59 | textAlignment: "PKTextAlignmentCenter",
60 | },
61 | {
62 | key: "sec3",
63 | label: "SB",
64 | value: "Sì",
65 | textAlignment: "PKTextAlignmentCenter",
66 | },
67 | {
68 | key: "sec4",
69 | label: "Imbarco",
70 | value: "Anteriore",
71 | textAlignment: "PKTextAlignmentCenter",
72 | },
73 | );
74 |
75 | pass.auxiliaryFields.push(
76 | {
77 | key: "aux1",
78 | label: "Passeggero",
79 | value: "MR. WHO KNOWS",
80 | textAlignment: "PKTextAlignmentLeft",
81 | },
82 | {
83 | key: "aux2",
84 | label: "Posto",
85 | value: "1A*",
86 | textAlignment: "PKTextAlignmentCenter",
87 | },
88 | );
89 |
90 | pass.backFields.push(
91 | {
92 | key: "document number",
93 | label: "Numero documento:",
94 | value: "- -",
95 | textAlignment: "PKTextAlignmentLeft",
96 | },
97 | {
98 | key: "You're checked in, what next",
99 | label: "Hai effettuato il check-in, Quali sono le prospettive",
100 | value: "",
101 | textAlignment: "PKTextAlignmentLeft",
102 | },
103 | {
104 | key: "Check In",
105 | label: "1. check-in✓",
106 | value: "",
107 | textAlignment: "PKTextAlignmentLeft",
108 | },
109 | {
110 | key: "checkIn",
111 | label: "",
112 | value: "Le uscite d'imbarco chiudono 30 minuti prima della partenza, quindi sii puntuale. In questo aeroporto puoi utilizzare la corsia Fast Track ai varchi di sicurezza.",
113 | textAlignment: "PKTextAlignmentLeft",
114 | },
115 | {
116 | key: "2. Bags",
117 | label: "2. Bagaglio",
118 | value: "",
119 | textAlignment: "PKTextAlignmentLeft",
120 | },
121 | {
122 | key: "Require special assistance",
123 | label: "Assistenza speciale",
124 | value: "Se hai richiesto assistenza speciale, presentati a un membro del personale nell'area di Consegna bagagli almeno 90 minuti prima del volo.",
125 | textAlignment: "PKTextAlignmentLeft",
126 | },
127 | {
128 | key: "3. Departures",
129 | label: "3. Partenze",
130 | value: "",
131 | textAlignment: "PKTextAlignmentLeft",
132 | },
133 | {
134 | key: "photoId",
135 | label: "Un documento d’identità corredato di fotografia",
136 | value: "è obbligatorio su TUTTI i voli. Per un viaggio internazionale è necessario un passaporto valido o, dove consentita, una carta d’identità.",
137 | textAlignment: "PKTextAlignmentLeft",
138 | },
139 | {
140 | key: "yourSeat",
141 | label: "Il tuo posto:",
142 | value: "verifica il tuo numero di posto nella parte superiore. Durante l’imbarco utilizza le scale anteriori e posteriori: per le file 1-10 imbarcati dalla parte anteriore; per le file 11-31 imbarcati dalla parte posteriore. Colloca le borse di dimensioni ridotte sotto il sedile davanti a te.",
143 | textAlignment: "PKTextAlignmentLeft",
144 | },
145 | {
146 | key: "Pack safely",
147 | label: "Bagaglio sicuro",
148 | value: "Fai clic http://easyjet.com/it/articoli-pericolosi per maggiori informazioni sulle merci pericolose oppure visita il sito CAA http://www.caa.co.uk/default.aspx?catid=2200",
149 | textAlignment: "PKTextAlignmentLeft",
150 | },
151 | {
152 | key: "Thank you for travelling easyJet",
153 | label: "Grazie per aver viaggiato con easyJet",
154 | value: "",
155 | textAlignment: "PKTextAlignmentLeft",
156 | },
157 | );
158 |
159 | return (await passGenerator.next(pass as PKPass)).value as ALBResult;
160 | }
161 |
--------------------------------------------------------------------------------
/examples/serverless/src/functions/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./barcodes.js";
2 | export * from "./expirationDate.js";
3 | export * from "./fields.js";
4 | export * from "./localize.js";
5 | export * from "./pkpasses.js";
6 | export * from "./scratch.js";
7 |
--------------------------------------------------------------------------------
/examples/serverless/src/functions/localize.ts:
--------------------------------------------------------------------------------
1 | import {
2 | throwClientErrorWithoutModelName,
3 | createPassGenerator,
4 | } from "../shared.js";
5 | import type { ALBEvent, ALBResult } from "aws-lambda";
6 | import type { PKPass } from "passkit-generator";
7 |
8 | /**
9 | * Lambda for localize example
10 | */
11 |
12 | export async function localize(event: ALBEvent) {
13 | try {
14 | throwClientErrorWithoutModelName(event);
15 | } catch (err) {
16 | return err;
17 | }
18 |
19 | const { modelName, ...passOptions } = event.queryStringParameters;
20 |
21 | const passGenerator = createPassGenerator(modelName, passOptions);
22 |
23 | const pass = (await passGenerator.next()).value as PKPass;
24 |
25 | /**
26 | * Italian and German already has an .lproj which gets included
27 | * but it doesn't have translations
28 | */
29 | pass.localize("it", {
30 | EVENT: "Evento",
31 | LOCATION: "Dove",
32 | });
33 |
34 | pass.localize("de", {
35 | EVENT: "Ereignis",
36 | LOCATION: "Ort",
37 | });
38 |
39 | // ...while Norwegian doesn't, so it gets created
40 | pass.localize("nn", {
41 | EVENT: "Begivenhet",
42 | LOCATION: "plassering",
43 | });
44 |
45 | console.log("Added languages", Object.keys(pass.languages).join(", "));
46 |
47 | return (await passGenerator.next(pass as PKPass)).value as ALBResult;
48 | }
49 |
--------------------------------------------------------------------------------
/examples/serverless/src/functions/pkpasses.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * PKPasses generation through PKPass.pack static method
3 | * example.
4 | * Here it is showed manual model reading and
5 | * creating through another PKPass because in the other
6 | * examples, creation through templates is already shown
7 | *
8 | * PLEASE NOTE THAT, AT TIME OF WRITING, THIS EXAMPLE WORKS
9 | * ONLY IF PASSES ARE DOWNLOADED FROM SAFARI, due to the
10 | * support of PKPasses archives. To test this, you might
11 | * need to open a tunnel through NGROK if you cannot access
12 | * to your local machine (in my personal case, developing
13 | * under WSL is a pretty big limitation sometimes).
14 | *
15 | * PLEASE ALSO NOTE that, AT TIME OF WRITING (iOS 15.0 - 15.2)
16 | * Pass Viewer suffers of a really curious bug: issuing several
17 | * passes within the same pkpasses archive, all with the same
18 | * serialNumber, will lead to have a broken view and to add
19 | * just one pass. You can see the screenshots below:
20 | *
21 | * https://imgur.com/bDTbcDg.jpg
22 | * https://imgur.com/Y4GpuHT.jpg
23 | * https://i.imgur.com/qbJMy1d.jpg
24 | *
25 | * - "Alberto, come to look at APPLE."
26 | * **Alberto looks**
27 | * - "MAMMA MIA!""
28 | *
29 | * A feedback to Apple have been sent for this.
30 | */
31 |
32 | import { ALBEvent } from "aws-lambda";
33 | import { PKPass } from "passkit-generator";
34 | import {
35 | getCertificates,
36 | getSpecificFileInModel,
37 | getS3Instance,
38 | getRandomColorPart,
39 | throwClientErrorWithoutModelName,
40 | } from "../shared.js";
41 | import config from "../../config.json";
42 |
43 | /**
44 | * Lambda for PkPasses example
45 | */
46 |
47 | export async function pkpasses(event: ALBEvent) {
48 | try {
49 | throwClientErrorWithoutModelName(event);
50 | } catch (err) {
51 | return err;
52 | }
53 |
54 | const [certificates, iconFromModel, s3] = await Promise.all([
55 | getCertificates(),
56 | getSpecificFileInModel(
57 | "icon.png",
58 | event.queryStringParameters.modelName,
59 | ),
60 | getS3Instance(),
61 | ]);
62 |
63 | function createPass() {
64 | const pass = new PKPass({}, certificates, {
65 | description: "Example Apple Wallet Pass",
66 | passTypeIdentifier: "pass.com.passkitgenerator",
67 | // Be sure to issue different serialNumbers or you might incur into the bug explained above
68 | serialNumber: `nmyuxofgna${Math.random()}`,
69 | organizationName: `Test Organization ${Math.random()}`,
70 | teamIdentifier: "F53WB8AE67",
71 | foregroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
72 | labelColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
73 | backgroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
74 | });
75 |
76 | pass.type = "boardingPass";
77 | pass.transitType = "PKTransitTypeAir";
78 |
79 | pass.headerFields.push(
80 | {
81 | key: "header-field-test-1",
82 | value: "Unknown",
83 | },
84 | {
85 | key: "header-field-test-2",
86 | value: "unknown",
87 | },
88 | );
89 |
90 | pass.primaryFields.push(
91 | {
92 | key: "primaryField-1",
93 | value: "NAP",
94 | },
95 | {
96 | key: "primaryField-2",
97 | value: "VCE",
98 | },
99 | );
100 |
101 | /**
102 | * Required by Apple. If one is not available, a
103 | * pass might be openable on a Mac but not on a
104 | * specific iPhone model
105 | */
106 |
107 | pass.addBuffer("icon.png", iconFromModel);
108 | pass.addBuffer("icon@2x.png", iconFromModel);
109 | pass.addBuffer("icon@3x.png", iconFromModel);
110 |
111 | return pass;
112 | }
113 |
114 | const passes = await Promise.all([
115 | Promise.resolve(createPass()),
116 | Promise.resolve(createPass()),
117 | Promise.resolve(createPass()),
118 | Promise.resolve(createPass()),
119 | ]);
120 |
121 | const pkpasses = PKPass.pack(...passes);
122 |
123 | /**
124 | * Although the other passes are served as files, in this example
125 | * we are uploading on s3 (local) just see how it works.
126 | */
127 |
128 | const buffer = pkpasses.getAsBuffer();
129 | const passName = `GeneratedPass-${Math.random()}.pkpasses`;
130 |
131 | const { Location } = await s3
132 | .upload({
133 | Bucket: config.PASSES_S3_TEMP_BUCKET,
134 | Key: passName,
135 | ContentType: pkpasses.mimeType,
136 | /** Marking it as expiring in 5 minutes, because passes should not be stored */
137 | Expires: new Date(Date.now() + 5 * 60 * 1000),
138 | Body: buffer,
139 | })
140 | .promise();
141 |
142 | /**
143 | * Please note that redirection to `Location` does not work
144 | * if you open this code in another device if this is is running
145 | * offline. This because `Location` is on localhost. Didn't
146 | * find yet a way to solve this.
147 | */
148 |
149 | return {
150 | statusCode: 302,
151 | headers: {
152 | "Content-Type": pkpasses.mimeType,
153 | Location,
154 | },
155 | };
156 | }
157 |
--------------------------------------------------------------------------------
/examples/serverless/src/functions/scratch.ts:
--------------------------------------------------------------------------------
1 | import { ALBEvent, ALBResult } from "aws-lambda";
2 | import { PKPass } from "passkit-generator";
3 | import {
4 | createPassGenerator,
5 | getRandomColorPart,
6 | getSpecificFileInModel,
7 | } from "../shared.js";
8 |
9 | /**
10 | * Lambda for scratch example
11 | */
12 |
13 | export async function scratch(event: ALBEvent) {
14 | const passGenerator = createPassGenerator(undefined, {
15 | description: "Example Apple Wallet Pass",
16 | passTypeIdentifier: "pass.com.passkitgenerator",
17 | serialNumber: "nmyuxofgna",
18 | organizationName: `Test Organization ${Math.random()}`,
19 | teamIdentifier: "F53WB8AE67",
20 | foregroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
21 | labelColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
22 | backgroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
23 | });
24 |
25 | const [{ value }, iconFromModel] = await Promise.all([
26 | passGenerator.next(),
27 | getSpecificFileInModel(
28 | "icon.png",
29 | event.queryStringParameters.modelName,
30 | ),
31 | ]);
32 |
33 | const pass = value as PKPass;
34 |
35 | pass.type = "boardingPass";
36 | pass.transitType = "PKTransitTypeAir";
37 |
38 | pass.headerFields.push(
39 | {
40 | key: "header-field-test-1",
41 | value: "Unknown",
42 | },
43 | {
44 | key: "header-field-test-2",
45 | value: "unknown",
46 | },
47 | );
48 |
49 | pass.primaryFields.push(
50 | {
51 | key: "primaryField-1",
52 | value: "NAP",
53 | },
54 | {
55 | key: "primaryField-2",
56 | value: "VCE",
57 | },
58 | );
59 |
60 | /**
61 | * Required by Apple. If one is not available, a
62 | * pass might be openable on a Mac but not on a
63 | * specific iPhone model
64 | */
65 |
66 | pass.addBuffer("icon.png", iconFromModel);
67 | pass.addBuffer("icon@2x.png", iconFromModel);
68 | pass.addBuffer("icon@3x.png", iconFromModel);
69 |
70 | return (await passGenerator.next(pass as PKPass)).value as ALBResult;
71 | }
72 |
--------------------------------------------------------------------------------
/examples/serverless/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./functions/index.js";
2 |
--------------------------------------------------------------------------------
/examples/serverless/src/shared.ts:
--------------------------------------------------------------------------------
1 | import { ALBEvent, ALBResult } from "aws-lambda";
2 | import AWS from "aws-sdk";
3 | import fs from "node:fs/promises";
4 | import path from "node:path";
5 | import { Buffer } from "node:buffer";
6 | import config from "../config.json";
7 | import { PKPass } from "passkit-generator";
8 |
9 | const S3: { instance: AWS.S3 } = { instance: undefined };
10 |
11 | export function throwClientErrorWithoutModelName(event: ALBEvent) {
12 | if (!event.queryStringParameters?.modelName) {
13 | throw {
14 | statusCode: 400,
15 | body: JSON.stringify({
16 | message: "modelName is missing in query params",
17 | }),
18 | };
19 | }
20 | }
21 |
22 | export function getRandomColorPart() {
23 | return Math.floor(Math.random() * 255);
24 | }
25 |
26 | export async function getModel(
27 | modelName: string,
28 | ): Promise {
29 | if (process.env.IS_OFFLINE === "true") {
30 | console.log("model offline retrieving");
31 |
32 | const standardModelName = modelName.endsWith(".pass")
33 | ? modelName
34 | : `${modelName}.pass`;
35 |
36 | return path.resolve(
37 | __dirname,
38 | "../../../",
39 | `models/${standardModelName}`,
40 | );
41 | }
42 |
43 | const s3 = await getS3Instance();
44 |
45 | const result = await s3
46 | .getObject({ Bucket: config.MODELS_S3_BUCKET, Key: modelName })
47 | .promise();
48 |
49 | return {}; // @TODO, like when it is run on s3
50 | }
51 |
52 | export async function getCertificates(): Promise<{
53 | signerCert: string | Buffer;
54 | signerKey: string | Buffer;
55 | wwdr: string | Buffer;
56 | signerKeyPassphrase?: string;
57 | }> {
58 | let signerCert: string;
59 | let signerKey: string;
60 | let wwdr: string;
61 | let signerKeyPassphrase: string;
62 |
63 | if (process.env.IS_OFFLINE) {
64 | console.log("Fetching Certificates locally");
65 |
66 | // ****************************************************************** //
67 | // *** Execution path offline is `examples/serverless/.build/src` *** //
68 | // ****************************************************************** //
69 |
70 | [signerCert, signerKey, wwdr, signerKeyPassphrase] = await Promise.all([
71 | fs.readFile(
72 | path.resolve(
73 | __dirname,
74 | "../../../../",
75 | "certificates/signerCert.pem",
76 | ),
77 | "utf-8",
78 | ),
79 | fs.readFile(
80 | path.resolve(
81 | __dirname,
82 | "../../../../",
83 | "certificates/signerKey.pem",
84 | ),
85 | "utf-8",
86 | ),
87 | fs.readFile(
88 | path.resolve(
89 | __dirname,
90 | "../../../../",
91 | "certificates/WWDR.pem",
92 | ),
93 | "utf-8",
94 | ),
95 | Promise.resolve(config.SIGNER_KEY_PASSPHRASE),
96 | ]);
97 | } else {
98 | // @TODO
99 | }
100 |
101 | return {
102 | signerCert,
103 | signerKey,
104 | wwdr,
105 | signerKeyPassphrase,
106 | };
107 | }
108 |
109 | export async function getS3Instance() {
110 | if (S3.instance) {
111 | return S3.instance;
112 | }
113 |
114 | const instance = new AWS.S3({
115 | s3ForcePathStyle: true,
116 | accessKeyId: process.env.IS_OFFLINE ? "S3RVER" : config.ACCESS_KEY_ID, // This specific key is required when working offline
117 | secretAccessKey: config.SECRET_ACCESS_KEY,
118 | endpoint: new AWS.Endpoint("http://localhost:4569"),
119 | });
120 |
121 | S3.instance = instance;
122 |
123 | try {
124 | /** Trying to create a new bucket. If it fails, it already exists (at least in theory) */
125 | await instance
126 | .createBucket({ Bucket: config.PASSES_S3_TEMP_BUCKET })
127 | .promise();
128 | } catch (err) {}
129 |
130 | return instance;
131 | }
132 |
133 | export async function getSpecificFileInModel(
134 | fileName: string,
135 | modelName: string,
136 | ) {
137 | const model = await getModel(modelName);
138 |
139 | if (typeof model === "string") {
140 | return fs.readFile(path.resolve(model, fileName));
141 | }
142 |
143 | return model[fileName];
144 | }
145 |
146 | export async function* createPassGenerator(
147 | modelName?: string,
148 | passOptions?: Object,
149 | ): AsyncGenerator {
150 | const [template, certificates, s3] = await Promise.all([
151 | modelName
152 | ? getModel(modelName)
153 | : Promise.resolve({} as ReturnType),
154 | getCertificates(),
155 | getS3Instance(),
156 | ]);
157 |
158 | let pass: PKPass;
159 |
160 | if (template instanceof Object) {
161 | pass = new PKPass(template, certificates, passOptions);
162 | } else if (typeof template === "string") {
163 | pass = await PKPass.from(
164 | {
165 | model: template,
166 | certificates,
167 | },
168 | passOptions,
169 | );
170 | }
171 |
172 | if (pass.type === "boardingPass" && !pass.transitType) {
173 | // Just to not make crash the creation if we use a boardingPass
174 | pass.transitType = "PKTransitTypeAir";
175 | }
176 |
177 | pass = yield pass;
178 |
179 | const buffer = pass.getAsBuffer();
180 |
181 | /**
182 | * Please note that redirection to `Location` does not work
183 | * if you open this code in another device if this is run
184 | * offline. This because `Location` is on localhost. Didn't
185 | * find yet a way to solve this.
186 | */
187 |
188 | return {
189 | statusCode: 200,
190 | headers: {
191 | "Content-Type": pass.mimeType,
192 | },
193 | /**
194 | * It is required for the file to be served
195 | * as base64, so it won't be altered in AWS.
196 | *
197 | * @see https://aws.amazon.com/it/blogs/compute/handling-binary-data-using-amazon-api-gateway-http-apis/
198 | * "For the response path, API Gateway inspects the isBase64Encoding flag returned from Lambda."
199 | */
200 | body: buffer.toString("base64"),
201 | isBase64Encoded: true,
202 | };
203 | }
204 |
--------------------------------------------------------------------------------
/examples/serverless/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "outDir": "build",
6 | "resolveJsonModule": true
7 | },
8 | "include": ["src/**/*"]
9 | }
10 |
--------------------------------------------------------------------------------
/jest.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | /**
4 | * @type {import("jest").Config}
5 | */
6 |
7 | export default {
8 | moduleFileExtensions: ["js", "mjs", "cjs"],
9 | testEnvironment: "node",
10 | testMatch: ["**/specs/**/*.spec.mjs"],
11 | injectGlobals: false,
12 | };
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "passkit-generator",
3 | "version": "3.4.0",
4 | "description": "The easiest way to generate custom Apple Wallet passes in Node.js",
5 | "main": "lib/cjs/index.js",
6 | "types": "lib/types/index.d.ts",
7 | "type": "module",
8 | "scripts": {
9 | "build": "rm -rf lib && pnpm tsc -b tsconfig.esm.json tsconfig.cjs.json && pnpm build:dual",
10 | "build:dual": "pnpm tsconfig-to-dual-package tsconfig.esm.json tsconfig.cjs.json",
11 | "build:all": "pnpm build && pnpm build:examples",
12 | "prepublishOnly": "pnpm build && pnpm test",
13 | "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" pnpm jest -c jest.config.mjs --silent"
14 | },
15 | "author": "Alexander Patrick Cerutti",
16 | "license": "MIT",
17 | "repository": "https://github.com/alexandercerutti/passkit-generator",
18 | "bugs": "https://github.com/alexandercerutti/passkit-generator/issues",
19 | "keywords": [
20 | "Apple",
21 | "Passkit",
22 | "Wallet",
23 | "Pass"
24 | ],
25 | "dependencies": {
26 | "do-not-zip": "^1.0.0",
27 | "joi": "17.4.2",
28 | "node-forge": "^1.3.1",
29 | "tslib": "^2.7.0"
30 | },
31 | "engines": {
32 | "node": ">=14.21.3"
33 | },
34 | "devDependencies": {
35 | "@jest/globals": "^29.7.0",
36 | "@types/do-not-zip": "^1.0.2",
37 | "@types/node": "^16.11.26",
38 | "@types/node-forge": "^1.3.11",
39 | "jest": "^29.7.0",
40 | "jest-environment-node": "^29.7.0",
41 | "prettier": "^3.3.3",
42 | "rimraf": "^4.4.1",
43 | "tsconfig-to-dual-package": "^1.2.0",
44 | "typescript": "^5.7.3"
45 | },
46 | "files": [
47 | "lib/cjs/**/*.+(js*)",
48 | "lib/esm/**/*.+(js*)",
49 | "lib/types/**/*.+(d.ts*)"
50 | ],
51 | "exports": {
52 | ".": {
53 | "types": "./lib/types/index.d.ts",
54 | "require": "./lib/cjs/index.js",
55 | "import": "./lib/esm/index.js"
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "."
3 | - "examples/*"
4 | - "examples/firebase/functions"
5 |
--------------------------------------------------------------------------------
/specs/utils.spec.mjs:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "@jest/globals";
2 | import { processDate, removeHidden } from "../lib/esm/utils.js";
3 |
4 | describe("Utils", () => {
5 | describe("removeHidden", () => {
6 | it("should remove files that start with dot", () => {
7 | const filesList = [
8 | "a.png",
9 | "b.png",
10 | ".DS_Store",
11 | "not_the_droids_you_are_looking_for.txt",
12 | ];
13 |
14 | expect(removeHidden(filesList)).toEqual([
15 | "a.png",
16 | "b.png",
17 | "not_the_droids_you_are_looking_for.txt",
18 | ]);
19 | });
20 | });
21 |
22 | describe("processDate", () => {
23 | it("should throw Invalid date if args[0] is not a date", () => {
24 | //@ts-expect-error
25 | expect(() => processDate(5)).toThrow("Invalid date");
26 | //@ts-expect-error
27 | expect(() => processDate({})).toThrow("Invalid date");
28 | //@ts-expect-error
29 | expect(() => processDate("ciao")).toThrow("Invalid date");
30 | //@ts-expect-error
31 | expect(() => processDate(true)).toThrow("Invalid date");
32 | });
33 |
34 | it("should convert a Date object to a valid W3C date", () => {
35 | expect(processDate(new Date("2020-07-01T02:00+02:00"))).toBe(
36 | "2020-07-01T00:00:00.000Z",
37 | );
38 | });
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/src/Bundle.ts:
--------------------------------------------------------------------------------
1 | import { Readable, Stream } from "node:stream";
2 | import { Buffer } from "node:buffer";
3 | import { toArray as zipToArray } from "do-not-zip";
4 | import * as Messages from "./messages.js";
5 |
6 | export const filesSymbol = Symbol("bundleFiles");
7 | export const freezeSymbol = Symbol("bundleFreeze");
8 | export const mimeTypeSymbol = Symbol("bundleMimeType");
9 |
10 | namespace Mime {
11 | export type type = string;
12 | export type subtype = string;
13 | }
14 |
15 | /**
16 | * Defines a container ready to be distributed.
17 | * If no mimeType is passed to the constructor,
18 | * it will throw an error.
19 | */
20 |
21 | export default class Bundle {
22 | private [filesSymbol]: { [key: string]: Buffer } = {};
23 | private [mimeTypeSymbol]: string;
24 |
25 | public constructor(mimeType: `${Mime.type}/${Mime.subtype}`) {
26 | if (!mimeType) {
27 | throw new Error(Messages.BUNDLE.MIME_TYPE_MISSING);
28 | }
29 |
30 | this[mimeTypeSymbol] = mimeType;
31 | }
32 |
33 | /**
34 | * Creates a bundle and exposes the
35 | * function to freeze it manually once
36 | * completed.
37 | *
38 | * This was made to not expose freeze
39 | * function outside of Bundle class.
40 | *
41 | * Normally, a bundle would get freezed
42 | * when using getAsBuffer or getAsStream
43 | * but when creating a PKPasses archive,
44 | * we need to freeze the bundle so the
45 | * user cannot add more files (we want to
46 | * allow them to only the selected files)
47 | * but also letting them choose how to
48 | * export it.
49 | *
50 | * @param mimeType
51 | * @returns
52 | */
53 |
54 | public static freezable(
55 | mimeType: `${Mime.type}/${Mime.subtype}`,
56 | ): [Bundle, Function] {
57 | const bundle = new Bundle(mimeType);
58 | return [bundle, () => bundle[freezeSymbol]()];
59 | }
60 |
61 | /**
62 | * Retrieves bundle's mimeType
63 | */
64 |
65 | public get mimeType() {
66 | return this[mimeTypeSymbol];
67 | }
68 |
69 | /**
70 | * Freezes the bundle so no more files
71 | * can be added any further.
72 | */
73 |
74 | private [freezeSymbol]() {
75 | if (this.isFrozen) {
76 | return;
77 | }
78 |
79 | Object.freeze(this[filesSymbol]);
80 | }
81 |
82 | /**
83 | * Tells if this bundle still allows files to be added.
84 | * @returns false if files are allowed, true otherwise
85 | */
86 |
87 | public get isFrozen() {
88 | return Object.isFrozen(this[filesSymbol]);
89 | }
90 |
91 | /**
92 | * Returns a copy of the current list of buffers
93 | * that have been added to the class.
94 | *
95 | * It does not include translation files, manifest
96 | * and signature.
97 | *
98 | * Final files list might differ due to export
99 | * conditions.
100 | */
101 |
102 | public get files() {
103 | return Object.keys(this[filesSymbol]);
104 | }
105 |
106 | /**
107 | * Allows files to be added to the bundle.
108 | * If the bundle is closed, it will throw an error.
109 | *
110 | * @param fileName
111 | * @param buffer
112 | */
113 |
114 | public addBuffer(fileName: string, buffer: Buffer) {
115 | if (this.isFrozen) {
116 | throw new Error(Messages.BUNDLE.CLOSED);
117 | }
118 |
119 | this[filesSymbol][fileName] = buffer;
120 | }
121 |
122 | /**
123 | * Closes the bundle and returns it as a Buffer.
124 | * Once closed, the bundle does not allow files
125 | * to be added any further.
126 | *
127 | * @returns Buffer
128 | */
129 |
130 | public getAsBuffer(): Buffer {
131 | this[freezeSymbol]();
132 | return Buffer.from(zipToArray(createZipFilesMap(this[filesSymbol])));
133 | }
134 |
135 | /**
136 | * Closes the bundle and returns it as a stream.
137 | * Once closed, the bundle does not allow files
138 | * to be added any further.
139 | *
140 | * @returns
141 | */
142 |
143 | public getAsStream(): Stream {
144 | this[freezeSymbol]();
145 | return Readable.from(
146 | Buffer.from(zipToArray(createZipFilesMap(this[filesSymbol]))),
147 | );
148 | }
149 |
150 | /**
151 | * Closes the bundle and returns it as an object.
152 | * This allows developers to choose a different way
153 | * of serving, analyzing or zipping the file, outside the
154 | * default compression system.
155 | *
156 | * @returns a frozen object containing files paths as key
157 | * and Buffers as content.
158 | */
159 |
160 | public getAsRaw(): { [filePath: string]: Buffer } {
161 | this[freezeSymbol]();
162 | return Object.freeze({ ...this[filesSymbol] });
163 | }
164 | }
165 |
166 | /**
167 | * Creates a files map for do-not-zip
168 | *
169 | * @param files
170 | * @returns
171 | */
172 |
173 | function createZipFilesMap(files: { [key: string]: Buffer }) {
174 | return Object.entries(files).map(([path, data]) => ({
175 | path,
176 | data,
177 | }));
178 | }
179 |
--------------------------------------------------------------------------------
/src/FieldsArray.ts:
--------------------------------------------------------------------------------
1 | import type PKPass from "./PKPass.js";
2 | import * as Schemas from "./schemas/index.js";
3 | import * as Utils from "./utils.js";
4 | import * as Messages from "./messages.js";
5 |
6 | /**
7 | * Class to represent lower-level keys pass fields
8 | * @see https://apple.co/2wkUBdh
9 | */
10 |
11 | const passInstanceSymbol = Symbol("passInstance");
12 | const sharedKeysPoolSymbol = Symbol("keysPool");
13 | const fieldSchemaSymbol = Symbol("fieldSchema");
14 |
15 | export default class FieldsArray extends Array {
16 | private [passInstanceSymbol]: InstanceType;
17 | private [sharedKeysPoolSymbol]: Set;
18 |
19 | constructor(
20 | passInstance: InstanceType,
21 | keysPool: Set,
22 | fieldSchema: typeof Schemas.Field | typeof Schemas.FieldWithRow,
23 | ...args: Schemas.Field[]
24 | ) {
25 | super(...args);
26 | this[fieldSchemaSymbol] = fieldSchema;
27 | this[passInstanceSymbol] = passInstance;
28 | this[sharedKeysPoolSymbol] = keysPool;
29 | }
30 |
31 | push(...items: Schemas.Field[]): number {
32 | const validItems = registerWithValidation(this, ...items);
33 | return super.push(...validItems);
34 | }
35 |
36 | pop(): Schemas.Field {
37 | return unregisterItems(this, () => super.pop());
38 | }
39 |
40 | splice(
41 | start: number,
42 | deleteCount: number,
43 | ...items: Schemas.Field[]
44 | ): Schemas.Field[] {
45 | // Perfoming frozen check, validation and getting valid items
46 | const validItems = registerWithValidation(this, ...items);
47 |
48 | for (let i = start; i < start + deleteCount; i++) {
49 | this[sharedKeysPoolSymbol].delete(this[i].key);
50 | }
51 |
52 | return super.splice(start, deleteCount, ...validItems);
53 | }
54 |
55 | shift() {
56 | return unregisterItems(this, () => super.shift());
57 | }
58 |
59 | unshift(...items: Schemas.Field[]) {
60 | const validItems = registerWithValidation(this, ...items);
61 | return super.unshift(...validItems);
62 | }
63 | }
64 |
65 | function registerWithValidation(
66 | instance: InstanceType,
67 | ...items: Schemas.Field[]
68 | ) {
69 | Utils.assertUnfrozen(instance[passInstanceSymbol]);
70 |
71 | let validItems: Schemas.Field[] = [];
72 |
73 | for (const field of items) {
74 | if (!field) {
75 | console.warn(Messages.format(Messages.FIELDS.INVALID, field));
76 | continue;
77 | }
78 |
79 | try {
80 | Schemas.assertValidity(
81 | instance[fieldSchemaSymbol],
82 | field,
83 | Messages.FIELDS.INVALID,
84 | );
85 |
86 | if (instance[sharedKeysPoolSymbol].has(field.key)) {
87 | throw new TypeError(
88 | Messages.format(Messages.FIELDS.REPEATED_KEY, field.key),
89 | );
90 | }
91 |
92 | instance[sharedKeysPoolSymbol].add(field.key);
93 | validItems.push(field);
94 | } catch (err) {
95 | if (err instanceof Error) {
96 | console.warn(err.message ? err.message : err);
97 | } else {
98 | console.warn(err);
99 | }
100 | }
101 | }
102 |
103 | return validItems;
104 | }
105 |
106 | function unregisterItems(
107 | instance: InstanceType,
108 | removeFn: Function,
109 | ) {
110 | Utils.assertUnfrozen(instance[passInstanceSymbol]);
111 |
112 | const element: Schemas.Field = removeFn();
113 | instance[sharedKeysPoolSymbol].delete(element.key);
114 | return element;
115 | }
116 |
--------------------------------------------------------------------------------
/src/Signature.ts:
--------------------------------------------------------------------------------
1 | import forge from "node-forge";
2 | import type * as Schemas from "./schemas/index.js";
3 | import { Buffer } from "node:buffer";
4 |
5 | /**
6 | * Creates an hash for a buffer. Used by manifest
7 | *
8 | * @param buffer
9 | * @returns
10 | */
11 |
12 | export function createHash(buffer: Buffer) {
13 | const hashFlow = forge.md.sha1.create();
14 | hashFlow.update(buffer.toString("binary"));
15 |
16 | return hashFlow.digest().toHex();
17 | }
18 |
19 | /**
20 | * Generates the PKCS #7 cryptografic signature for the manifest file.
21 | *
22 | * @method create
23 | * @params manifest
24 | * @params certificates
25 | * @returns
26 | */
27 |
28 | export function create(
29 | manifestBuffer: Buffer,
30 | certificates: Schemas.CertificatesSchema,
31 | ): Buffer {
32 | const signature = forge.pkcs7.createSignedData();
33 |
34 | signature.content = new forge.util.ByteStringBuffer(manifestBuffer);
35 |
36 | const { wwdr, signerCert, signerKey } = parseCertificates(
37 | getStringCertificates(certificates),
38 | );
39 |
40 | signature.addCertificate(wwdr);
41 | signature.addCertificate(signerCert);
42 |
43 | /**
44 | * authenticatedAttributes belong to PKCS#9 standard.
45 | * It requires at least 2 values:
46 | * • content-type (which is a PKCS#7 oid) and
47 | * • message-digest oid.
48 | *
49 | * Wallet requires a signingTime.
50 | */
51 |
52 | signature.addSigner({
53 | key: signerKey,
54 | certificate: signerCert,
55 | digestAlgorithm: forge.pki.oids.sha1,
56 | authenticatedAttributes: [
57 | {
58 | type: forge.pki.oids.contentType,
59 | value: forge.pki.oids.data,
60 | },
61 | {
62 | type: forge.pki.oids.messageDigest,
63 | },
64 | {
65 | type: forge.pki.oids.signingTime,
66 | },
67 | ],
68 | });
69 |
70 | /**
71 | * We are creating a detached signature because we don't need the signed content.
72 | * Detached signature is a property of PKCS#7 cryptography standard.
73 | */
74 |
75 | signature.sign({ detached: true });
76 |
77 | /**
78 | * Signature here is an ASN.1 valid structure (DER-compliant).
79 | * Generating a non-detached signature, would have pushed inside signature.contentInfo
80 | * (which has type 16, or "SEQUENCE", and is an array) a Context-Specific element, with the
81 | * signed content as value.
82 | *
83 | * In fact the previous approach was to generating a detached signature and the pull away the generated
84 | * content.
85 | *
86 | * That's what happens when you copy a fu****g line without understanding what it does.
87 | * Well, nevermind, it was funny to study BER, DER, CER, ASN.1 and PKCS#7. You can learn a lot
88 | * of beautiful things. ¯\_(ツ)_/¯
89 | */
90 |
91 | return Buffer.from(
92 | forge.asn1.toDer(signature.toAsn1()).getBytes(),
93 | "binary",
94 | );
95 | }
96 |
97 | /**
98 | * Parses the PEM-formatted passed text (certificates)
99 | *
100 | * @param element - Text content of .pem files
101 | * @param passphrase - passphrase for the key
102 | * @returns The parsed certificate or key in node forge format
103 | */
104 |
105 | function parseCertificates(certificates: Schemas.CertificatesSchema) {
106 | const { signerCert, signerKey, wwdr, signerKeyPassphrase } = certificates;
107 |
108 | return {
109 | signerCert: forge.pki.certificateFromPem(signerCert.toString("utf-8")),
110 | wwdr: forge.pki.certificateFromPem(wwdr.toString("utf-8")),
111 | signerKey: forge.pki.decryptRsaPrivateKey(
112 | signerKey.toString("utf-8"),
113 | signerKeyPassphrase,
114 | ),
115 | };
116 | }
117 |
118 | function getStringCertificates(
119 | certificates: Schemas.CertificatesSchema,
120 | ): Record<
121 | keyof Omit,
122 | string
123 | > & { signerKeyPassphrase?: string } {
124 | return {
125 | signerKeyPassphrase: certificates.signerKeyPassphrase,
126 | wwdr: Buffer.from(certificates.wwdr).toString("utf-8"),
127 | signerCert: Buffer.from(certificates.signerCert).toString("utf-8"),
128 | signerKey: Buffer.from(certificates.signerKey).toString("utf-8"),
129 | };
130 | }
131 |
--------------------------------------------------------------------------------
/src/StringsUtils.ts:
--------------------------------------------------------------------------------
1 | import { EOL } from "node:os";
2 | import { Buffer } from "node:buffer";
3 |
4 | // ************************************ //
5 | // *** UTILS FOR PASS.STRINGS FILES *** //
6 | // ************************************ //
7 |
8 | /**
9 | * Parses a string file to convert it to
10 | * an object
11 | *
12 | * @param buffer
13 | * @returns
14 | */
15 |
16 | export function parse(buffer: Buffer) {
17 | const fileAsString = buffer.toString("utf8");
18 | const translationRowRegex = /"(?.+)"\s+=\s+"(?.+)";\n?/;
19 | const commentRowRegex = /\/\*\s*(.+)\s*\*\//;
20 |
21 | let translations: [placeholder: string, value: string][] = [];
22 | let comments: string[] = [];
23 |
24 | let blockStartPoint = 0;
25 | let blockEndPoint = 0;
26 |
27 | do {
28 | if (
29 | /** New Line, new life */
30 | /\n/.test(fileAsString[blockEndPoint]) ||
31 | /** EOF */
32 | blockEndPoint === fileAsString.length
33 | ) {
34 | let match: RegExpMatchArray | null;
35 |
36 | const section = fileAsString.substring(
37 | blockStartPoint,
38 | blockEndPoint + 1,
39 | );
40 |
41 | if ((match = section.match(translationRowRegex)) && match.groups) {
42 | const {
43 | groups: { key, value },
44 | } = match;
45 |
46 | translations.push([key, value]);
47 | } else if ((match = section.match(commentRowRegex))) {
48 | const [, content] = match;
49 |
50 | comments.push(content.trimEnd());
51 | }
52 |
53 | /** Skipping \n and going to the next block. */
54 | blockEndPoint += 2;
55 | blockStartPoint = blockEndPoint - 1;
56 | } else {
57 | blockEndPoint += 1;
58 | }
59 | } while (blockEndPoint <= fileAsString.length);
60 |
61 | return {
62 | translations,
63 | comments,
64 | };
65 | }
66 |
67 | /**
68 | * Creates a strings file buffer
69 | *
70 | * @param translations
71 | * @returns
72 | */
73 |
74 | export function create(translations: { [key: string]: string }): Buffer {
75 | const stringContents = [];
76 |
77 | const translationsEntries = Object.entries(translations);
78 |
79 | for (let i = 0; i < translationsEntries.length; i++) {
80 | const [key, value] = translationsEntries[i];
81 |
82 | stringContents.push(`"${key}" = "${value}";`);
83 | }
84 |
85 | return Buffer.from(stringContents.join(EOL));
86 | }
87 |
--------------------------------------------------------------------------------
/src/getModelFolderContents.ts:
--------------------------------------------------------------------------------
1 | import * as path from "node:path";
2 | import { promises as fs } from "node:fs";
3 | import type { Buffer } from "node:buffer";
4 | import * as Utils from "./utils.js";
5 | import * as Messages from "./messages.js";
6 |
7 | /**
8 | * Reads the model folder contents
9 | *
10 | * @param model
11 | * @returns A promise of an object containing all
12 | * filePaths and the relative buffer
13 | */
14 |
15 | export default async function getModelFolderContents(
16 | model: string,
17 | ): Promise<{ [filePath: string]: Buffer }> {
18 | try {
19 | const modelPath = `${model}${(!path.extname(model) && ".pass") || ""}`;
20 | const modelFilesList = await fs.readdir(modelPath);
21 |
22 | // No dot-starting files, manifest and signature and only files with an extension
23 | const modelSuitableRootPaths = Utils.removeHidden(
24 | modelFilesList,
25 | ).filter(
26 | (f) =>
27 | !/(manifest|signature)/i.test(f) &&
28 | /.+$/.test(path.parse(f).ext),
29 | );
30 |
31 | const modelRecords = await Promise.all(
32 | modelSuitableRootPaths.map((fileOrDirectoryPath) =>
33 | readFileOrDirectory(
34 | path.resolve(modelPath, fileOrDirectoryPath),
35 | ),
36 | ),
37 | );
38 |
39 | return Object.fromEntries(modelRecords.flat(1));
40 | } catch (err) {
41 | if (!isErrorErrNoException(err) || !isMissingFileError(err)) {
42 | throw err;
43 | }
44 |
45 | if (isFileReadingFailure(err)) {
46 | throw new Error(
47 | Messages.format(
48 | Messages.MODELS.FILE_NO_OPEN,
49 | JSON.stringify(err),
50 | ),
51 | );
52 | }
53 |
54 | if (isDirectoryReadingFailure(err)) {
55 | throw new Error(
56 | Messages.format(Messages.MODELS.DIR_NOT_FOUND, err.path),
57 | );
58 | }
59 |
60 | throw err;
61 | }
62 | }
63 |
64 | function isErrorErrNoException(err: unknown): err is NodeJS.ErrnoException {
65 | return Object.prototype.hasOwnProperty.call(err, "errno");
66 | }
67 |
68 | function isMissingFileError(
69 | err: unknown,
70 | ): err is NodeJS.ErrnoException & { code: "ENOENT" } {
71 | return (err as NodeJS.ErrnoException).code === "ENOENT";
72 | }
73 |
74 | function isDirectoryReadingFailure(
75 | err: NodeJS.ErrnoException,
76 | ): err is NodeJS.ErrnoException & { syscall: "scandir" } {
77 | return err.syscall === "scandir";
78 | }
79 |
80 | function isFileReadingFailure(
81 | err: NodeJS.ErrnoException,
82 | ): err is NodeJS.ErrnoException & { syscall: "open" } {
83 | return err.syscall === "open";
84 | }
85 |
86 | /**
87 | * Allows reading both a whole directory or a set of
88 | * file in the same flow
89 | *
90 | * @param filePath
91 | * @returns
92 | */
93 |
94 | async function readFileOrDirectory(
95 | filePath: string,
96 | ): Promise<[key: string, content: Buffer][]> {
97 | const stats = await fs.lstat(filePath);
98 |
99 | if (stats.isDirectory()) {
100 | return readFilesInDirectory(filePath);
101 | } else {
102 | return getFileContents(filePath).then((result) => [result]);
103 | }
104 | }
105 |
106 | /**
107 | * Reads a directory and returns all
108 | * the files in it
109 | *
110 | * @param filePath
111 | * @returns
112 | */
113 |
114 | async function readFilesInDirectory(
115 | filePath: string,
116 | ): Promise>[]> {
117 | const dirContent = await fs.readdir(filePath).then(Utils.removeHidden);
118 |
119 | return Promise.all(
120 | dirContent.map((fileName) =>
121 | getFileContents(path.resolve(filePath, fileName), 2),
122 | ),
123 | );
124 | }
125 |
126 | /**
127 | * @param filePath
128 | * @param pathSlicesDepthFromEnd used to preserve localization lproj content
129 | * @returns
130 | */
131 |
132 | async function getFileContents(
133 | filePath: string,
134 | pathSlicesDepthFromEnd: number = 1,
135 | ): Promise<[key: string, content: Buffer]> {
136 | const fileComponents = filePath.split(path.sep);
137 | const fileName = fileComponents
138 | .slice(fileComponents.length - pathSlicesDepthFromEnd)
139 | .join("/");
140 |
141 | const content = await fs.readFile(filePath);
142 |
143 | return [fileName, content];
144 | }
145 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as PKPass } from "./PKPass.js";
2 |
3 | // ***************************************** //
4 | // *** Exporting only schemas interfaces *** //
5 | // ***************************************** //
6 |
7 | export type {
8 | Barcode,
9 | Beacon,
10 | Field,
11 | Location,
12 | NFC,
13 | PassProps,
14 | Semantics,
15 | TransitType,
16 | Personalize,
17 | OverridablePassProps,
18 | } from "./schemas/index.js";
19 |
--------------------------------------------------------------------------------
/src/messages.ts:
--------------------------------------------------------------------------------
1 | export const INIT = {
2 | INVALID_BUFFERS:
3 | "Cannot set buffers in constructor: expected object but received %s",
4 | } as const;
5 |
6 | export const CERTIFICATES = {
7 | INVALID:
8 | "Invalid certificate(s) loaded. %s. Please provide valid WWDR certificates and developer signer certificate and key (with passphrase).\nRefer to docs to obtain them",
9 | } as const;
10 |
11 | export const TRANSIT_TYPE = {
12 | UNEXPECTED_PASS_TYPE:
13 | "Cannot set transitType on a pass with type different from boardingPass.",
14 | INVALID:
15 | "Cannot set transitType because not compliant with Apple specifications. Refer to https://apple.co/3DHuAG4 for more - %s",
16 | } as const;
17 |
18 | export const PREFERRED_STYLE_SCHEMES = {
19 | UNEXPECTED_PASS_TYPE_SET:
20 | "Cannot set preferredStyleSchemes on a pass with type different from eventTicket.",
21 | UNEXPECTED_PASS_TYPE_GET:
22 | "Cannot get preferredStyleSchemes on a pass with type different from eventTicket.",
23 | INVALID:
24 | "Cannot set preferredStyleSchemes because not compliant with Apple specifications - %s",
25 | } as const;
26 |
27 | export const PASS_TYPE = {
28 | INVALID:
29 | "Cannot set type because not compliant with Apple specifications. Refer to https://apple.co/3aFpSfg for a list of valid props - %s",
30 | } as const;
31 |
32 | export const TEMPLATE = {
33 | INVALID: "Cannot create pass from a template. %s",
34 | } as const;
35 |
36 | export const FILTER_VALID = {
37 | INVALID: "Cannot validate property. %s",
38 | } as const;
39 |
40 | export const FIELDS = {
41 | INVALID: "Cannot add field. %s",
42 | REPEATED_KEY:
43 | "Cannot add field with key '%s': another field already owns this key. Ignored.",
44 | } as const;
45 |
46 | export const RELEVANT_DATE = {
47 | INVALID: "Cannot set relevant date. Date format is invalid",
48 | } as const;
49 |
50 | export const DATE = {
51 | INVALID: "Cannot set %s. Invalid date %s",
52 | } as const;
53 |
54 | export const LANGUAGES = {
55 | INVALID_LANG:
56 | "Cannot set localization. Expected a string for 'lang' but received %s",
57 | NO_TRANSLATIONS:
58 | "Cannot create or use language %s. If your itention was to just add a language (.lproj) folder to the bundle, both specify some translations or use .addBuffer to add some media.",
59 | } as const;
60 |
61 | export const BARCODES = {
62 | INVALID_POST: "",
63 | } as const;
64 |
65 | export const PASS_SOURCE = {
66 | INVALID: "Cannot add pass.json to bundle because it is invalid. %s",
67 | UNKNOWN_TYPE:
68 | "Cannot find a valid type in pass.json. You won't be able to set fields until you won't set explicitly one.",
69 | JOIN: "The imported pass.json's properties will be joined with the current setted props. You might lose some data.",
70 | } as const;
71 |
72 | export const PERSONALIZE = {
73 | INVALID:
74 | "Cannot add personalization.json to bundle because it is invalid. %s",
75 | } as const;
76 |
77 | export const JSON = {
78 | INVALID: "Cannot parse JSON. Invalid file",
79 | } as const;
80 |
81 | export const CLOSE = {
82 | MISSING_TYPE: "Cannot proceed creating the pass because type is missing.",
83 | MISSING_ICON:
84 | "At least one icon file is missing in your bundle. Your pass won't be openable by any Apple Device.",
85 | PERSONALIZATION_REMOVED:
86 | "Personalization file '%s' have been removed from the bundle as the requirements for personalization are not met.",
87 | MISSING_TRANSIT_TYPE:
88 | "Cannot proceed creating the pass because transitType is missing on your boardingPass.",
89 | } as const;
90 |
91 | export const MODELS = {
92 | DIR_NOT_FOUND: "Cannot import model: directory %s not found.",
93 | FILE_NO_OPEN: "Cannot open model file. %s",
94 | } as const;
95 |
96 | export const BUNDLE = {
97 | MIME_TYPE_MISSING: "Cannot build Bundle. MimeType is missing",
98 | CLOSED: "Cannot add file or set property. Bundle is closed.",
99 | } as const;
100 |
101 | export const FROM = {
102 | MISSING_SOURCE: "Cannot create PKPass from source: source is '%s'",
103 | } as const;
104 |
105 | export const PACK = {
106 | INVALID: "Cannot pack passes. Only PKPass instances allowed",
107 | } as const;
108 |
109 | /**
110 | * Creates a message with replaced values
111 | * @param messageName
112 | * @param values
113 | */
114 |
115 | export function format(messageName: string, ...values: any[]) {
116 | // reversing because it is better popping than shifting.
117 | const replaceValues = values.reverse();
118 | return messageName.replace(/%s/g, () => replaceValues.pop());
119 | }
120 |
--------------------------------------------------------------------------------
/src/schemas/Barcode.ts:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 |
3 | /**
4 | * @see https://developer.apple.com/documentation/walletpasses/pass/barcodes
5 | */
6 |
7 | export type BarcodeFormat =
8 | | "PKBarcodeFormatQR"
9 | | "PKBarcodeFormatPDF417"
10 | | "PKBarcodeFormatAztec"
11 | | "PKBarcodeFormatCode128";
12 |
13 | export interface Barcode {
14 | altText?: string;
15 | messageEncoding?: string;
16 | format: BarcodeFormat;
17 | message: string;
18 | }
19 |
20 | export const Barcode = Joi.object().keys({
21 | altText: Joi.string(),
22 | messageEncoding: Joi.string().default("iso-8859-1"),
23 | format: Joi.string()
24 | .required()
25 | .regex(
26 | /(PKBarcodeFormatQR|PKBarcodeFormatPDF417|PKBarcodeFormatAztec|PKBarcodeFormatCode128)/,
27 | "barcodeType",
28 | ),
29 | message: Joi.string().required(),
30 | });
31 |
--------------------------------------------------------------------------------
/src/schemas/Beacon.ts:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 |
3 | /**
4 | * @see https://developer.apple.com/documentation/walletpasses/pass/beacons
5 | */
6 |
7 | export interface Beacon {
8 | major?: number;
9 | minor?: number;
10 | relevantText?: string;
11 | proximityUUID: string;
12 | }
13 |
14 | export const Beacon = Joi.object().keys({
15 | major: Joi.number().integer().min(0).max(65535),
16 | minor: Joi.number().integer().min(0).max(65535),
17 | proximityUUID: Joi.string().required(),
18 | relevantText: Joi.string(),
19 | });
20 |
--------------------------------------------------------------------------------
/src/schemas/Certificates.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from "node:buffer";
2 | import Joi from "joi";
3 |
4 | export interface CertificatesSchema {
5 | wwdr: string | Buffer;
6 | signerCert: string | Buffer;
7 | signerKey: string | Buffer;
8 | signerKeyPassphrase?: string;
9 | }
10 |
11 | /**
12 | * Joi.binary is not available in browser-like environments (like Cloudflare workers)
13 | * so we fallback to manual checking. Buffer must be polyfilled.
14 | */
15 |
16 | const binary = Joi.binary
17 | ? Joi.binary()
18 | : Joi.custom((obj) => Buffer.isBuffer(obj));
19 |
20 | export const CertificatesSchema = Joi.object()
21 | .keys({
22 | wwdr: Joi.alternatives(binary, Joi.string()).required(),
23 | signerCert: Joi.alternatives(binary, Joi.string()).required(),
24 | signerKey: Joi.alternatives(binary, Joi.string()).required(),
25 | signerKeyPassphrase: Joi.string(),
26 | })
27 | .required();
28 |
--------------------------------------------------------------------------------
/src/schemas/Field.ts:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 | import { Semantics } from "./Semantics.js";
3 |
4 | export type PKDataDetectorType =
5 | | "PKDataDetectorTypePhoneNumber"
6 | | "PKDataDetectorTypeLink"
7 | | "PKDataDetectorTypeAddress"
8 | | "PKDataDetectorTypeCalendarEvent";
9 |
10 | export type PKTextAlignmentType =
11 | | "PKTextAlignmentLeft"
12 | | "PKTextAlignmentCenter"
13 | | "PKTextAlignmentRight"
14 | | "PKTextAlignmentNatural";
15 |
16 | export type PKDateStyleType =
17 | | "PKDateStyleNone"
18 | | "PKDateStyleShort"
19 | | "PKDateStyleMedium"
20 | | "PKDateStyleLong"
21 | | "PKDateStyleFull";
22 |
23 | export type PKNumberStyleType =
24 | | "PKNumberStyleDecimal"
25 | | "PKNumberStylePercent"
26 | | "PKNumberStyleScientific"
27 | | "PKNumberStyleSpellOut";
28 |
29 | /**
30 | * @see https://developer.apple.com/documentation/walletpasses/passfieldcontent
31 | */
32 |
33 | export interface Field {
34 | attributedValue?: string | number | Date;
35 | changeMessage?: string;
36 | dataDetectorTypes?: PKDataDetectorType[];
37 | label?: string;
38 | textAlignment?: PKTextAlignmentType;
39 | key: string;
40 | value: string | number | Date;
41 | semantics?: Semantics;
42 | dateStyle?: PKDateStyleType;
43 | ignoresTimeZone?: boolean;
44 | isRelative?: boolean;
45 | timeStyle?: PKDateStyleType;
46 | currencyCode?: string;
47 | numberStyle?: PKNumberStyleType;
48 | }
49 |
50 | export interface FieldWithRow extends Field {
51 | row?: 0 | 1;
52 | }
53 |
54 | export const Field = Joi.object().keys({
55 | attributedValue: Joi.alternatives(
56 | Joi.string().allow(""),
57 | Joi.number(),
58 | Joi.date().iso(),
59 | ),
60 | changeMessage: Joi.string(),
61 | dataDetectorTypes: Joi.array().items(
62 | Joi.string().regex(
63 | /(PKDataDetectorTypePhoneNumber|PKDataDetectorTypeLink|PKDataDetectorTypeAddress|PKDataDetectorTypeCalendarEvent)/,
64 | "dataDetectorType",
65 | ),
66 | ),
67 | label: Joi.string().allow(""),
68 | textAlignment: Joi.string().regex(
69 | /(PKTextAlignmentLeft|PKTextAlignmentCenter|PKTextAlignmentRight|PKTextAlignmentNatural)/,
70 | "graphic-alignment",
71 | ),
72 | key: Joi.string().required(),
73 | value: Joi.alternatives(
74 | Joi.string().allow(""),
75 | Joi.number(),
76 | Joi.date().iso(),
77 | ).required(),
78 | semantics: Semantics,
79 | // date fields formatters, all optionals
80 | dateStyle: Joi.string().regex(
81 | /(PKDateStyleNone|PKDateStyleShort|PKDateStyleMedium|PKDateStyleLong|PKDateStyleFull)/,
82 | "date style",
83 | ),
84 | ignoresTimeZone: Joi.boolean(),
85 | isRelative: Joi.boolean(),
86 | timeStyle: Joi.string().regex(
87 | /(PKDateStyleNone|PKDateStyleShort|PKDateStyleMedium|PKDateStyleLong|PKDateStyleFull)/,
88 | "date style",
89 | ),
90 | // number fields formatters, all optionals
91 | currencyCode: Joi.string().when("value", {
92 | is: Joi.number(),
93 | otherwise: Joi.string().forbidden(),
94 | }),
95 | numberStyle: Joi.string()
96 | .regex(
97 | /(PKNumberStyleDecimal|PKNumberStylePercent|PKNumberStyleScientific|PKNumberStyleSpellOut)/,
98 | )
99 | .when("value", {
100 | is: Joi.number(),
101 | otherwise: Joi.string().forbidden(),
102 | }),
103 | });
104 |
105 | export const FieldWithRow = Field.concat(
106 | Joi.object().keys({
107 | row: Joi.number().min(0).max(1),
108 | }),
109 | );
110 |
--------------------------------------------------------------------------------
/src/schemas/Location.ts:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 |
3 | /**
4 | * @see https://developer.apple.com/documentation/walletpasses/pass/locations
5 | */
6 |
7 | export interface Location {
8 | relevantText?: string;
9 | altitude?: number;
10 | latitude: number;
11 | longitude: number;
12 | }
13 |
14 | export const Location = Joi.object().keys({
15 | altitude: Joi.number(),
16 | latitude: Joi.number().required(),
17 | longitude: Joi.number().required(),
18 | relevantText: Joi.string(),
19 | });
20 |
--------------------------------------------------------------------------------
/src/schemas/NFC.ts:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 |
3 | /**
4 | * @see https://developer.apple.com/documentation/walletpasses/pass/nfc
5 | */
6 |
7 | export interface NFC {
8 | message: string;
9 | encryptionPublicKey: string;
10 | requiresAuthentication?: boolean;
11 | }
12 |
13 | export const NFC = Joi.object().keys({
14 | message: Joi.string().required().max(64),
15 | encryptionPublicKey: Joi.string().required(),
16 | requiresAuthentication: Joi.boolean(),
17 | });
18 |
--------------------------------------------------------------------------------
/src/schemas/PassFields.ts:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 | import { Field, FieldWithRow } from "./Field.js";
3 |
4 | export type TransitType =
5 | | "PKTransitTypeAir"
6 | | "PKTransitTypeBoat"
7 | | "PKTransitTypeBus"
8 | | "PKTransitTypeGeneric"
9 | | "PKTransitTypeTrain";
10 |
11 | export const TransitType = Joi.string().regex(
12 | /(PKTransitTypeAir|PKTransitTypeBoat|PKTransitTypeBus|PKTransitTypeGeneric|PKTransitTypeTrain)/,
13 | );
14 |
15 | export interface PassFields {
16 | auxiliaryFields: FieldWithRow[];
17 | backFields: Field[];
18 | headerFields: Field[];
19 | primaryFields: Field[];
20 | secondaryFields: Field[];
21 | transitType?: TransitType;
22 |
23 | /**
24 | * @iOSVersion 18
25 | * @passStyle eventTicket (new layout)
26 | * @passDomain dashboard
27 | *
28 | * @see \
29 | */
30 | additionalInfoFields?: Field[];
31 | }
32 |
33 | export const PassFields = Joi.object().keys({
34 | auxiliaryFields: Joi.array().items(FieldWithRow),
35 | backFields: Joi.array().items(Field),
36 | headerFields: Joi.array().items(Field),
37 | primaryFields: Joi.array().items(Field),
38 | secondaryFields: Joi.array().items(Field),
39 | transitType: TransitType,
40 |
41 | /**
42 | * @iOSVersion 18
43 | * @passStyle eventTicket (new layout)
44 | * @passDomain dashboard
45 | *
46 | * @see \
47 | */
48 | additionalInfoFields: Joi.array().items(Field),
49 | });
50 |
--------------------------------------------------------------------------------
/src/schemas/Personalize.ts:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 |
3 | /**
4 | * @see https://developer.apple.com/documentation/walletpasses/personalize
5 | */
6 |
7 | type RequiredPersonalizationFields =
8 | | "PKPassPersonalizationFieldName"
9 | | "PKPassPersonalizationFieldPostalCode"
10 | | "PKPassPersonalizationFieldEmailAddress"
11 | | "PKPassPersonalizationFieldPhoneNumber";
12 |
13 | export interface Personalize {
14 | description: string;
15 | requiredPersonalizationFields: RequiredPersonalizationFields[];
16 | termsAndConditions?: string;
17 | }
18 |
19 | export const Personalize = Joi.object().keys({
20 | description: Joi.string().required(),
21 | requiredPersonalizationFields: Joi.array()
22 | .items(
23 | "PKPassPersonalizationFieldName",
24 | "PKPassPersonalizationFieldPostalCode",
25 | "PKPassPersonalizationFieldEmailAddress",
26 | "PKPassPersonalizationFieldPhoneNumber",
27 | )
28 | .required(),
29 | termsAndConditions: Joi.string(),
30 | });
31 |
--------------------------------------------------------------------------------
/src/schemas/SemanticTagType.ts:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 | import { RGB_HEX_COLOR_REGEX } from "./regexps.js";
3 |
4 | /**
5 | * These couple of structures are organized alphabetically,
6 | * according to the order on the developer documentation.
7 | *
8 | * @see https://developer.apple.com/documentation/walletpasses/semantictagtype
9 | */
10 |
11 | /**
12 | * @see https://developer.apple.com/documentation/walletpasses/semantictagtype/currencyamount-data.dictionary
13 | */
14 | export interface CurrencyAmount {
15 | currencyCode?: string; // ISO 4217 currency code
16 | amount?: string;
17 | }
18 |
19 | export const CurrencyAmount = Joi.object().keys({
20 | currencyCode: Joi.string(),
21 | amount: Joi.string(),
22 | });
23 |
24 | /**
25 | * @iOSVersion 18
26 | * @passStyle eventTicket (new layout)
27 | *
28 | * @see \
29 | */
30 |
31 | export interface EventDateInfo {
32 | date: string;
33 | ignoreTimeComponents?: boolean;
34 | timeZone?: string;
35 | }
36 |
37 | export const EventDateInfo = Joi.object().keys({
38 | date: Joi.string().isoDate().required(),
39 | ignoreTimeComponents: Joi.boolean(),
40 | timeZone: Joi.string(),
41 | });
42 |
43 | /**
44 | * @see https://developer.apple.com/documentation/walletpasses/semantictagtype/location-data.dictionary
45 | */
46 | export interface Location {
47 | latitude: number;
48 | longitude: number;
49 | }
50 |
51 | export const Location = Joi.object().keys({
52 | latitude: Joi.number().required(),
53 | longitude: Joi.number().required(),
54 | });
55 |
56 | /**
57 | * @see https://developer.apple.com/documentation/walletpasses/semantictagtype/personnamecomponents-data.dictionary
58 | */
59 | export interface PersonNameComponents {
60 | familyName?: string;
61 | givenName?: string;
62 | middleName?: string;
63 | namePrefix?: string;
64 | nameSuffix?: string;
65 | nickname?: string;
66 | phoneticRepresentation?: string;
67 | }
68 |
69 | export const PersonNameComponents = Joi.object().keys({
70 | givenName: Joi.string(),
71 | familyName: Joi.string(),
72 | middleName: Joi.string(),
73 | namePrefix: Joi.string(),
74 | nameSuffix: Joi.string(),
75 | nickname: Joi.string(),
76 | phoneticRepresentation: Joi.string(),
77 | });
78 |
79 | /**
80 | * @see https://developer.apple.com/documentation/walletpasses/semantictagtype/seat-data.dictionary
81 | */
82 | export interface Seat {
83 | seatSection?: string;
84 | seatRow?: string;
85 | seatNumber?: string;
86 | seatIdentifier?: string;
87 | seatType?: string;
88 | seatDescription?: string;
89 |
90 | /**
91 | * @iOSVersion 18
92 | * @passStyle eventTicket (new layout)
93 | */
94 | seatAisle?: string;
95 |
96 | /**
97 | * @iOSVersion 18
98 | * @passStyle eventTicket (new layout)
99 | */
100 | seatLevel?: string;
101 |
102 | /**
103 | * @iOSVersion 18
104 | * @passStyle eventTicket (new layout)
105 | */
106 | seatSectionColor?: string;
107 | }
108 |
109 | export const Seat = Joi.object().keys({
110 | seatSection: Joi.string(),
111 | seatRow: Joi.string(),
112 | seatNumber: Joi.string(),
113 | seatIdentifier: Joi.string(),
114 | seatType: Joi.string(),
115 | seatDescription: Joi.string(),
116 |
117 | /**
118 | * @iOSVersion 18
119 | * @passStyle eventTicket (new layout)
120 | */
121 | seatAisle: Joi.string(),
122 |
123 | /**
124 | * @iOSVersion 18
125 | * @passStyle eventTicket (new layout)
126 | */
127 | seatLevel: Joi.string(),
128 |
129 | /**
130 | * @iOSVersion 18
131 | * @passStyle eventTicket (new layout)
132 | */
133 | seatSectionColor: Joi.string().regex(RGB_HEX_COLOR_REGEX),
134 | });
135 |
136 | /**
137 | * @see https://developer.apple.com/documentation/walletpasses/semantictagtype/wifinetwork-data.dictionary
138 | */
139 | export interface WifiNetwork {
140 | password: string;
141 | ssid: string;
142 | }
143 |
144 | export const WifiNetwork = Joi.object().keys({
145 | password: Joi.string().required(),
146 | ssid: Joi.string().required(),
147 | });
148 |
--------------------------------------------------------------------------------
/src/schemas/Semantics.ts:
--------------------------------------------------------------------------------
1 | import Joi from "joi";
2 | import * as SemanticTagType from "./SemanticTagType.js";
3 |
4 | /**
5 | * For a better description of every single field,
6 | * please refer to Apple official documentation.
7 | *
8 | * @see https://developer.apple.com/documentation/walletpasses/semantictags
9 | */
10 |
11 | /**
12 | * Alphabetical order
13 | * @see https://developer.apple.com/documentation/walletpasses/semantictags
14 | */
15 |
16 | export interface Semantics {
17 | /**
18 | * @iOSVersion 18
19 | * @passStyle eventTicket (new layout)
20 | */
21 | admissionLevel?: string;
22 |
23 | /**
24 | * @iOSVersion 18
25 | * @passStyle eventTicket (new layout)
26 | */
27 | admissionLevelAbbreviation?: string;
28 |
29 | airlineCode?: string;
30 | artistIDs?: string[];
31 |
32 | /**
33 | * @iOSVersion 18
34 | * @passStyle eventTicket (new layout)
35 | */
36 | albumIDs?: string[];
37 |
38 | /**
39 | * @iOSVersion 18
40 | * @passStyle eventTicket (new layout)
41 | */
42 | airplay?: {
43 | airPlayDeviceGroupToken: string;
44 | }[];
45 |
46 | /**
47 | * @iOSVersion 18
48 | * @passStyle eventTicket (new layout)
49 | */
50 | attendeeName?: string;
51 |
52 | awayTeamAbbreviation?: string;
53 | awayTeamLocation?: string;
54 | awayTeamName?: string;
55 |
56 | /**
57 | * @iOSVersion 18
58 | * @passStyle eventTicket (new layout)
59 | */
60 | additionalTicketAttributes?: string;
61 |
62 | balance?: SemanticTagType.CurrencyAmount;
63 | boardingGroup?: string;
64 | boardingSequenceNumber?: string;
65 |
66 | carNumber?: string;
67 | confirmationNumber?: string;
68 | currentArrivalDate?: string;
69 | currentBoardingDate?: string;
70 | currentDepartureDate?: string;
71 |
72 | departureAirportCode?: string;
73 | departureAirportName?: string;
74 | departureGate?: string;
75 | departureLocation?: SemanticTagType.Location;
76 | departureLocationDescription?: string;
77 | departurePlatform?: string;
78 | departureStationName?: string;
79 | departureTerminal?: string;
80 | destinationAirportCode?: string;
81 | destinationAirportName?: string;
82 | destinationGate?: string;
83 | destinationLocation?: SemanticTagType.Location;
84 | destinationLocationDescription?: string;
85 | destinationPlatform?: string;
86 | destinationStationName?: string;
87 | destinationTerminal?: string;
88 | duration?: number;
89 |
90 | /**
91 | * @iOSVersion 18
92 | * @passStyle eventTicket (new layout)
93 | */
94 | entranceDescription?: string;
95 |
96 | eventEndDate?: string;
97 |
98 | /**
99 | * @iOSVersion 18
100 | * @passStyle eventTicket (new layout)
101 | *
102 | * Shows a message in the live activity
103 | * when the activity starts.
104 | */
105 | eventLiveMessage?: string;
106 |
107 | eventName?: string;
108 | eventStartDate?: string;
109 |
110 | /**
111 | * @iOSVersion 18
112 | * @passStyle eventTicket (new layout).
113 | *
114 | * Can be used as an alternative way to
115 | * show show start date, with more control
116 | * on time and timeZone details and as
117 | * a way to show the event guide, both
118 | * instead of `eventStartDate`.
119 | */
120 | eventStartDateInfo?: SemanticTagType.EventDateInfo;
121 |
122 | /**
123 | * @iOSVersion < 18
124 | * Since iOS 18, for the event tickets these determine
125 | * the template to be used when rendering the pass.
126 | *
127 | * - Generic Template
128 | * - "PKEventTypeGeneric"
129 | * - "PKEventTypeMovie"
130 | * - "PKEventTypeConference"
131 | * - "PKEventTypeConvention"
132 | * - "PKEventTypeWorkshop"
133 | * - "PKEventTypeSocialGathering"
134 | * - Sport Template
135 | * - "PKEventTypeSports"
136 | * - Live Performance Template
137 | * - "PKEventTypeLivePerformance";
138 | */
139 |
140 | eventType?:
141 | | "PKEventTypeGeneric"
142 | | "PKEventTypeMovie"
143 | | "PKEventTypeConference"
144 | | "PKEventTypeConvention"
145 | | "PKEventTypeWorkshop"
146 | | "PKEventTypeSocialGathering"
147 | | "PKEventTypeSports"
148 | | "PKEventTypeLivePerformance";
149 |
150 | flightCode?: string;
151 | flightNumber?: number;
152 |
153 | genre?: string;
154 |
155 | homeTeamAbbreviation?: string;
156 | homeTeamLocation?: string;
157 | homeTeamName?: string;
158 | leagueAbbreviation?: string;
159 | leagueName?: string;
160 |
161 | membershipProgramName?: string;
162 | membershipProgramNumber?: string;
163 |
164 | originalArrivalDate?: string;
165 | originalBoardingDate?: string;
166 | originalDepartureDate?: string;
167 |
168 | passengerName?: SemanticTagType.PersonNameComponents;
169 | performerNames?: string[];
170 | priorityStatus?: string;
171 |
172 | /**
173 | * @iOSVersion 18
174 | * @passStyle eventTicket (new layout)
175 | */
176 | playlistIDs?: string[];
177 |
178 | seats?: SemanticTagType.Seat[];
179 | securityScreening?: string;
180 | silenceRequested?: boolean;
181 | sportName?: string;
182 |
183 | /**
184 | * @iOSVersion 18
185 | * @passStyle eventTicket (new layout)
186 | */
187 | tailgatingAllowed?: boolean;
188 |
189 | totalPrice?: SemanticTagType.CurrencyAmount;
190 | transitProvider?: string;
191 | transitStatus?: string;
192 | transitStatusReason?: string;
193 |
194 | vehicleName?: string;
195 | vehicleNumber?: string;
196 | vehicleType?: string;
197 |
198 | venueEntrance?: string;
199 | venueLocation?: SemanticTagType.Location;
200 |
201 | /**
202 | * @iOSVersion 18
203 | * @passStyle eventTicket (new layout)
204 | */
205 | venueGatesOpenDate?: string;
206 |
207 | venueName?: string;
208 |
209 | /**
210 | * @iOSVersion 18
211 | * @passStyle eventTicket (new layout)
212 | */
213 | venueParkingLotsOpenDate?: string;
214 |
215 | /**
216 | * @iOSVersion 18
217 | * @passStyle eventTicket (new layout)
218 | */
219 | venueBoxOfficeOpenDate?: string;
220 |
221 | /**
222 | * @iOSVersion 18
223 | * @passStyle eventTicket (new layout)
224 | */
225 | venueDoorsOpenDate?: string;
226 |
227 | /**
228 | * @iOSVersion 18
229 | * @passStyle eventTicket (new layout)
230 | */
231 | venueFanZoneOpenDate?: string;
232 |
233 | /**
234 | * @iOSVersion 18
235 | * @passStyle eventTicket (new layout)
236 | */
237 | venueOpenDate?: string;
238 |
239 | /**
240 | * @iOSVersion 18
241 | * @passStyle eventTicket (new layout)
242 | */
243 | venueCloseDate?: string;
244 |
245 | venuePhoneNumber?: string;
246 | venueRoom?: string;
247 |
248 | /**
249 | * @iOSVersion 18
250 | * @passStyle eventTicket (new layout)
251 | */
252 | venueRegionName?: string;
253 |
254 | /**
255 | * @iOSVersion 18
256 | * @passStyle eventTicket (new layout)
257 | */
258 | venueEntranceGate?: string;
259 |
260 | /**
261 | * @iOSVersion 18
262 | * @passStyle eventTicket (new layout)
263 | */
264 | venueEntranceDoor?: string;
265 |
266 | /**
267 | * @iOSVersion 18
268 | * @passStyle eventTicket (new layout)
269 | */
270 | venueEntrancePortal?: string;
271 |
272 | wifiAccess?: SemanticTagType.WifiNetwork[];
273 | }
274 |
275 | export const Semantics = Joi.object().keys({
276 | /**
277 | * @iOSVersion 18
278 | * @passStyle eventTicket (new layout)
279 | */
280 | admissionLevel: Joi.string(),
281 |
282 | /**
283 | * @iOSVersion 18
284 | * @passStyle eventTicket (new layout)
285 | */
286 | admissionLevelAbbreviation: Joi.string(),
287 |
288 | airlineCode: Joi.string(),
289 | artistIDs: Joi.array().items(Joi.string()),
290 |
291 | /**
292 | * @iOSVersion 18
293 | * @passStyle eventTicket (new layout)
294 | */
295 | albumIDs: Joi.array().items(Joi.string()),
296 |
297 | /**
298 | * @iOSVersion 18
299 | * @passStyle eventTicket (new layout)
300 | */
301 | airplay: Joi.array().items({
302 | airplayDeviceGroupToken: Joi.string(),
303 | }),
304 |
305 | /**
306 | * @iOSVersion 18
307 | * @passStyle eventTicket (new layout)
308 | */
309 | attendeeName: Joi.string(),
310 |
311 | awayTeamAbbreviation: Joi.string(),
312 | awayTeamLocation: Joi.string(),
313 | awayTeamName: Joi.string(),
314 |
315 | additionalTicketAttributes: Joi.string(),
316 |
317 | balance: SemanticTagType.CurrencyAmount,
318 | boardingGroup: Joi.string(),
319 | boardingSequenceNumber: Joi.string(),
320 |
321 | carNumber: Joi.string(),
322 | confirmationNumber: Joi.string(),
323 | currentArrivalDate: Joi.string(),
324 | currentBoardingDate: Joi.string(),
325 | currentDepartureDate: Joi.string(),
326 |
327 | departureAirportCode: Joi.string(),
328 | departureAirportName: Joi.string(),
329 | departureGate: Joi.string(),
330 | departureLocation: SemanticTagType.Location,
331 | departureLocationDescription: Joi.string(),
332 | departurePlatform: Joi.string(),
333 | departureStationName: Joi.string(),
334 | departureTerminal: Joi.string(),
335 | destinationAirportCode: Joi.string(),
336 | destinationAirportName: Joi.string(),
337 | destinationGate: Joi.string(),
338 | destinationLocation: SemanticTagType.Location,
339 | destinationLocationDescription: Joi.string(),
340 | destinationPlatform: Joi.string(),
341 | destinationStationName: Joi.string(),
342 | destinationTerminal: Joi.string(),
343 | duration: Joi.number(),
344 |
345 | /**
346 | * @iOSVersion 18
347 | * @passStyle eventTicket (new layout)
348 | */
349 | entranceDescription: Joi.string(),
350 |
351 | eventEndDate: Joi.string(),
352 | eventName: Joi.string(),
353 |
354 | /**
355 | * @iOSVersion 18
356 | * @passStyle eventTicket (new layout)
357 | *
358 | * Shows a message in the live activity
359 | * when the activity starts.
360 | */
361 | eventLiveMessage: Joi.string(),
362 |
363 | /**
364 | * @iOSVersion 18
365 | * @passStyle eventTicket (new layout).
366 | *
367 | * Can be used as an alternative way to
368 | * show show start date, with more control
369 | * on time and timeZone details and as
370 | * a way to show the event guide, both
371 | * instead of `eventStartDate`.
372 | */
373 | eventStartDateInfo: SemanticTagType.EventDateInfo,
374 |
375 | eventStartDate: Joi.string(),
376 | eventType: Joi.string().regex(
377 | /(PKEventTypeGeneric|PKEventTypeLivePerformance|PKEventTypeMovie|PKEventTypeSports|PKEventTypeConference|PKEventTypeConvention|PKEventTypeWorkshop|PKEventTypeSocialGathering)/,
378 | ),
379 |
380 | flightCode: Joi.string(),
381 | flightNumber: Joi.number(),
382 |
383 | genre: Joi.string(),
384 |
385 | homeTeamAbbreviation: Joi.string(),
386 | homeTeamLocation: Joi.string(),
387 | homeTeamName: Joi.string(),
388 | leagueAbbreviation: Joi.string(),
389 | leagueName: Joi.string(),
390 |
391 | membershipProgramName: Joi.string(),
392 | membershipProgramNumber: Joi.string(),
393 |
394 | originalArrivalDate: Joi.string(),
395 | originalBoardingDate: Joi.string(),
396 | originalDepartureDate: Joi.string(),
397 |
398 | passengerName: SemanticTagType.PersonNameComponents,
399 | performerNames: Joi.array().items(Joi.string()),
400 | priorityStatus: Joi.string(),
401 |
402 | playlistIDs: Joi.array().items(Joi.string()),
403 |
404 | seats: Joi.array().items(SemanticTagType.Seat),
405 | securityScreening: Joi.string(),
406 | silenceRequested: Joi.boolean(),
407 | sportName: Joi.string(),
408 |
409 | tailgatingAllowed: Joi.boolean(),
410 |
411 | totalPrice: SemanticTagType.CurrencyAmount,
412 | transitProvider: Joi.string(),
413 | transitStatus: Joi.string(),
414 | transitStatusReason: Joi.string(),
415 |
416 | vehicleName: Joi.string(),
417 | vehicleNumber: Joi.string(),
418 | vehicleType: Joi.string(),
419 |
420 | venueEntrance: Joi.string(),
421 |
422 | /**
423 | * @iOSVersion 18
424 | * @passStyle eventTicket (new layout)
425 | */
426 | venueGatesOpenDate: Joi.string(),
427 |
428 | venueLocation: SemanticTagType.Location,
429 | venueName: Joi.string(),
430 |
431 | /**
432 | * @iOSVersion 18
433 | * @passStyle eventTicket (new layout)
434 | */
435 | venueParkingLotsOpenDate: Joi.string(),
436 |
437 | /**
438 | * @iOSVersion 18
439 | * @passStyle eventTicket (new layout)
440 | */
441 | venueBoxOfficeOpenDate: Joi.string(),
442 |
443 | /**
444 | * @iOSVersion 18
445 | * @passStyle eventTicket (new layout)
446 | */
447 | venueDoorsOpenDate: Joi.string(),
448 |
449 | /**
450 | * @iOSVersion 18
451 | * @passStyle eventTicket (new layout)
452 | */
453 | venueFanZoneOpenDate: Joi.string(),
454 |
455 | /**
456 | * @iOSVersion 18
457 | * @passStyle eventTicket (new layout)
458 | */
459 | venueOpenDate: Joi.string(),
460 |
461 | /**
462 | * @iOSVersion 18
463 | * @passStyle eventTicket (new layout)
464 | */
465 | venueCloseDate: Joi.string(),
466 |
467 | venuePhoneNumber: Joi.string(),
468 | venueRoom: Joi.string(),
469 |
470 | /**
471 | * @iOSVersion 18
472 | * @passStyle eventTicket (new layout)
473 | */
474 | venueRegionName: Joi.string(),
475 |
476 | /**
477 | * @iOSVersion 18
478 | * @passStyle eventTicket (new layout)
479 | */
480 | venueEntranceGate: Joi.string(),
481 |
482 | /**
483 | * @iOSVersion 18
484 | * @passStyle eventTicket (new layout)
485 | */
486 | venueEntranceDoor: Joi.string(),
487 |
488 | /**
489 | * @iOSVersion 18
490 | * @passStyle eventTicket (new layout)
491 | */
492 | venueEntrancePortal: Joi.string(),
493 |
494 | wifiAccess: Joi.array().items(SemanticTagType.WifiNetwork),
495 | });
496 |
--------------------------------------------------------------------------------
/src/schemas/regexps.ts:
--------------------------------------------------------------------------------
1 | export const RGB_HEX_COLOR_REGEX =
2 | /(?:\#[a-fA-F0-9]{3,6}|rgb\(\s*(?:[01]?[0-9][0-9]?|2[0-4][0-9]|25[0-5])\s*,\s*(?:[01]?[0-9][0-9]?|2[0-4][0-9]|25[0-5])\s*,\s*(?:[01]?[0-9][0-9]?|2[0-4][0-9]|25[0-5])\s*\))/;
3 | export const URL_REGEX = /https?:\/\/(?:[a-z0-9]+\.?)+(?::\d{2,})?(?:\/[\S]+)*/;
4 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import * as Messages from "./messages.js";
2 | import type Bundle from "./Bundle.js";
3 |
4 | /**
5 | * Converts a date to W3C / UTC string
6 | * @param date
7 | * @returns
8 | */
9 |
10 | export function processDate(date: Date): string | undefined {
11 | if (!(date instanceof Date) || Number.isNaN(Number(date))) {
12 | throw "Invalid date";
13 | }
14 |
15 | /**
16 | * @see https://www.w3.org/TR/NOTE-datetime
17 | */
18 |
19 | return date.toISOString();
20 | }
21 |
22 | /**
23 | * Removes hidden files from a list (those starting with dot)
24 | *
25 | * @params from - list of file names
26 | * @return
27 | */
28 |
29 | export function removeHidden(from: Array): Array {
30 | return from.filter((e) => e.charAt(0) !== ".");
31 | }
32 |
33 | /**
34 | * Clones recursively an object and all of its properties
35 | *
36 | * @param object
37 | * @returns
38 | */
39 |
40 | export function cloneRecursive(object: T) {
41 | const objectCopy = {} as Record;
42 | const objectEntries = Object.entries(object) as [keyof T, T[keyof T]][];
43 |
44 | for (let i = 0; i < objectEntries.length; i++) {
45 | const [key, value] = objectEntries[i];
46 |
47 | if (value && typeof value === "object") {
48 | if (Array.isArray(value)) {
49 | objectCopy[key] = value.slice();
50 |
51 | for (let j = 0; j < value.length; j++) {
52 | objectCopy[key][j] = cloneRecursive(value[j]);
53 | }
54 | } else {
55 | objectCopy[key] = cloneRecursive(value);
56 | }
57 | } else {
58 | objectCopy[key] = value;
59 | }
60 | }
61 |
62 | return objectCopy;
63 | }
64 |
65 | export function assertUnfrozen(
66 | instance: InstanceType,
67 | ): asserts instance is Bundle & { isFrozen: false } {
68 | if (instance.isFrozen) {
69 | throw new Error(Messages.BUNDLE.CLOSED);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/tsconfig.cjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "declaration": true,
5 | "declarationDir": "lib/types",
6 | "outDir": "lib/cjs",
7 | "inlineSourceMap": true,
8 | "skipLibCheck": true,
9 | "module": "CommonJS",
10 | "moduleResolution": "Node10"
11 | },
12 | "include": ["src/**/*"]
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.esm.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "declaration": true,
5 | "declarationDir": "lib/types",
6 | "outDir": "lib/esm",
7 | "inlineSourceMap": true,
8 | "skipLibCheck": true,
9 | "module": "Node16",
10 | "moduleResolution": "Node16"
11 | },
12 | "include": ["src/**/*"]
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2018",
4 | "esModuleInterop": true,
5 | "newLine": "LF",
6 | "importHelpers": true,
7 | "useUnknownInCatchVariables": true,
8 | "moduleResolution": "Node16",
9 | "module": "Node16"
10 | },
11 | "include": ["src/**/*"]
12 | }
13 |
--------------------------------------------------------------------------------