├── .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 | Passkit-generator logo for light mode 14 | 15 |
16 |
17 |

Simple Node.js interface to generate customized Apple Wallet Passes for iOS and WatchOS.

18 | 19 | ![](https://img.shields.io/npm/v/passkit-generator.svg?label=passkit-generator) 20 | ![](https://img.shields.io/node/v/passkit-generator.svg) 21 |
22 | [![Financial Contributors on Open Collective](https://opencollective.com/passkit-generator/all/badge.svg?label=financial+contributors)](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 | --------------------------------------------------------------------------------