├── .codecov.yml ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ ├── lint.yml │ ├── npmpublish.yml │ └── test.yml ├── .gitignore ├── .huskyrc ├── .npmrc ├── .prettierrc.json ├── .sonarcloud.properties ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── __tests__ ├── __snapshots__ │ ├── images.ts.snap │ ├── localizations.ts.snap │ └── pass.ts.snap ├── base-pass.ts ├── fieldsMap.ts ├── get-geo-point.ts ├── images.ts ├── localizations.ts ├── nfc-field.ts ├── normalize-locale.ts ├── pass.ts ├── resources │ ├── bin │ │ └── signpass │ ├── icon.png │ ├── icon@2x.png │ ├── logo.png │ ├── logo@2x.png │ ├── passes │ │ ├── BoardingPass.pass │ │ │ ├── icon.png │ │ │ ├── icon@2x.png │ │ │ ├── logo.png │ │ │ ├── logo@2x.png │ │ │ └── pass.json │ │ ├── Event.pass │ │ │ ├── background.png │ │ │ ├── background@2x.png │ │ │ ├── icon.png │ │ │ ├── icon@2x.png │ │ │ ├── logo.png │ │ │ ├── logo@2x.png │ │ │ ├── pass.json │ │ │ ├── thumbnail.png │ │ │ └── thumbnail@2x.png │ │ ├── Generic.zip │ │ ├── Generic │ │ │ ├── icon.png │ │ │ ├── ru.lproj │ │ │ │ ├── logo.png │ │ │ │ ├── logo@2x.png │ │ │ │ └── pass.strings │ │ │ └── zh_CN.lproj │ │ │ │ ├── logo.png │ │ │ │ ├── logo@2x.png │ │ │ │ └── pass.strings │ │ └── StoreCard.pkpass │ ├── strip.png │ ├── strip@2x.png │ ├── thumbnail.png │ └── thumbnail@2x.png ├── signManifest.ts ├── template.ts └── w3cdate.ts ├── bin ├── .eslintrc └── passkit-keys ├── images ├── background.png ├── background@2x.png ├── background@3x.png ├── footer.png ├── footer@2x.png ├── footer@3x.png ├── icon.png ├── icon@2x.png ├── icon@3x.png ├── logo.png ├── logo@2x.png ├── logo@3x.png ├── strip.png ├── strip@2x.png ├── strip@3x.png ├── thumbnail.png ├── thumbnail@2x.png └── thumbnail@3x.png ├── jest.config.js ├── keys ├── .gitignore └── wwdr.pem ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── @types │ ├── do-not-zip │ │ └── index.d.ts │ └── imagesize │ │ └── index.d.ts ├── constants.ts ├── index.ts ├── interfaces.ts ├── lib │ ├── base-pass.ts │ ├── fieldsMap.ts │ ├── get-geo-point.ts │ ├── getBufferHash.ts │ ├── images.ts │ ├── localizations.ts │ ├── nfc-fields.ts │ ├── normalize-locale.ts │ ├── pass-color.ts │ ├── pass-structure.ts │ ├── signManifest-forge.ts │ ├── stream-to-buffer.ts │ ├── w3cdate.ts │ └── yazul-promisified.ts ├── pass.ts └── template.ts ├── tsconfig.all.json └── tsconfig.json /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | parsers: 3 | javascript: 4 | enable_partials: yes 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "project": "./tsconfig.all.json", 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["@destinationstransfers", "@typescript-eslint"], 9 | "extends": [ 10 | "plugin:@destinationstransfers/recommended", 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 14 | "prettier", 15 | "prettier/@typescript-eslint" 16 | ], 17 | "env": { 18 | "node": true, 19 | "browser": false 20 | }, 21 | "rules": { 22 | "no-undef": "off", // conflicts with optional bindings 23 | "@typescript-eslint/no-use-before-define": "off", // conflicts with optonal binding 24 | "prettier/prettier": "off", 25 | "@destinationstransfers/prefer-class-properties": "off", 26 | "node/no-unsupported-features/es-syntax": "off", 27 | "node/no-unsupported-features/node-builtins": [ 28 | "error", 29 | { "ignores": ["fs.promises"] } 30 | ], 31 | "import/no-unresolved": "off", 32 | "dependencies/no-unresolved": "off", 33 | "@typescript-eslint/explicit-member-accessibility": [ 34 | "error", 35 | { 36 | "accessibility": "no-public" 37 | } 38 | ], 39 | "@typescript-eslint/no-var-requires": "off", 40 | "@typescript-eslint/camelcase": "off", 41 | "@typescript-eslint/member-ordering": "error", 42 | "@typescript-eslint/type-annotation-spacing": "error", 43 | "@typescript-eslint/no-extraneous-class": "error", 44 | "@typescript-eslint/no-non-null-assertion": "error", 45 | "@typescript-eslint/explicit-function-return-type": [ 46 | "error", 47 | { "allowExpressions": true, "allowTypedFunctionExpressions": true } 48 | ], 49 | "@typescript-eslint/consistent-type-assertions": [ 50 | "error", 51 | { 52 | "assertionStyle": "as", 53 | "objectLiteralTypeAssertions": "allow-as-parameter" // Allow type assertion in call and new expression, default false 54 | } 55 | ], 56 | "@typescript-eslint/no-this-alias": [ 57 | "error", 58 | { 59 | "allowDestructuring": true // Allow `const { props, state } = this`; false by default 60 | } 61 | ], 62 | // note you must disable the base rule as it can report incorrect errors 63 | "no-unused-vars": "off", 64 | "@typescript-eslint/no-unused-vars": [ 65 | "error", 66 | { 67 | "vars": "all", 68 | "args": "after-used", 69 | "ignoreRestSiblings": true, 70 | "argsIgnorePattern": "^next$", 71 | "caughtErrors": "none" 72 | } 73 | ], 74 | "@typescript-eslint/ban-types": [ 75 | "error", 76 | { 77 | "types": { 78 | "Array": null, 79 | "Object": "Use {} instead", 80 | "String": { 81 | "message": "Use string instead", 82 | "fixWith": "string" 83 | } 84 | } 85 | } 86 | ], 87 | // requires project 88 | "@typescript-eslint/promise-function-async": "error", 89 | "@typescript-eslint/no-for-in-array": "error" 90 | }, 91 | "overrides": [ 92 | { 93 | "files": ["__tests__/*.ts", "*.test.ts"], 94 | "env": { 95 | "jest": true 96 | }, 97 | "rules": { 98 | "@typescript-eslint/no-unused-vars": "warn", 99 | "@typescript-eslint/explicit-function-return-type": "off" 100 | } 101 | } 102 | ], 103 | "settings": { 104 | "import/extensions": [".ts"], 105 | "import/parsers": { 106 | "@typescript-eslint/parser": [".ts"] 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: tinovyatkin 4 | 5 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '**.ts' 7 | - '**eslint**' 8 | - 'package-lock.json' 9 | - '.github/workflows/lint.yml' 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | env: 15 | CI: true 16 | HUSKY_SKIP_INSTALL: true 17 | FORCE_COLOR: 2 18 | 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | 24 | - name: Install Node.js 25 | uses: actions/setup-node@v3 26 | 27 | - name: Install Packages 28 | run: npm ci 29 | 30 | - name: Lint 31 | run: npm run -s lint 32 | 33 | - name: Typecheck 34 | run: npx tsc --noEmit 35 | -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | - run: npm ci 15 | - run: node --expose-gc node_modules/jest/bin/jest --forceExit --logHeapUsage --runInBand --ci --reporters=default --reporters=jest-junit --colors 16 | env: 17 | APPLE_PASS_PRIVATE_KEY: ${{secrets.APPLE_PASS_PRIVATE_KEY}} 18 | APPLE_PASS_CERTIFICATE: ${{secrets.APPLE_PASS_CERTIFICATE}} 19 | APPLE_PASS_KEY_PASSWORD: ${{secrets.APPLE_PASS_KEY_PASSWORD}} 20 | - name: Upload client coverage to Codecov 21 | # https://github.com/codecov/codecov-bash/blob/1044b7a243e0ea0c05ed43c2acd8b7bb7cef340c/codecov#L158 22 | run: bash <(curl -s https://codecov.io/bash) 23 | -f ./coverage/coverage-final.json 24 | -B master 25 | -C ${{ github.sha }} 26 | -Z || echo 'Codecov upload failed' 27 | env: 28 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 29 | 30 | publish-npm: 31 | needs: build 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v3 35 | - uses: actions/setup-node@v3 36 | with: 37 | registry-url: https://registry.npmjs.org/ 38 | scope: '@walletpass' 39 | - run: npm ci 40 | - run: npm publish --access public 41 | env: 42 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 43 | 44 | publish-gpr: 45 | needs: build 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v3 49 | - uses: actions/setup-node@v3 50 | with: 51 | registry-url: https://npm.pkg.github.com/ 52 | scope: '@walletpass' 53 | - run: npm ci 54 | - run: npm publish --access public 55 | env: 56 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 57 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '**.ts' 7 | - 'package-lock.json' 8 | - '.github/workflows/test.yml' 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | env: 14 | CI: true 15 | HUSKY_SKIP_INSTALL: true 16 | FORCE_COLOR: 2 17 | 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest, windows-latest, macOS-latest] 21 | node: [13.x, 12.x, 10.x] 22 | exclude: 23 | # On Windows, run tests with only the latest environments. 24 | - os: windows-latest 25 | node: 10.x 26 | - os: windows-latest 27 | node: 13.x 28 | # On macOS, run tests with only the latest environments. 29 | - os: macOS-latest 30 | node: 10.x 31 | - os: macOS-latest 32 | node: 13.x 33 | 34 | runs-on: ${{ matrix.os }} 35 | steps: 36 | - uses: actions/checkout@v3 37 | with: 38 | fetch-depth: 1 39 | 40 | - uses: actions/setup-node@v3 41 | with: 42 | node-version: ${{ matrix.node }} 43 | 44 | - run: npm ci 45 | 46 | - run: node --expose-gc node_modules/jest/bin/jest --forceExit --colors --logHeapUsage --runInBand --ci --reporters=default --reporters=jest-junit 47 | env: 48 | APPLE_PASS_PRIVATE_KEY: ${{secrets.APPLE_PASS_PRIVATE_KEY}} 49 | APPLE_PASS_CERTIFICATE: ${{secrets.APPLE_PASS_CERTIFICATE}} 50 | APPLE_PASS_KEY_PASSWORD: ${{secrets.APPLE_PASS_KEY_PASSWORD}} 51 | 52 | - name: Upload coverage to Codecov 53 | # https://github.com/codecov/codecov-bash/blob/1044b7a243e0ea0c05ed43c2acd8b7bb7cef340c/codecov#L158 54 | run: npx codecov -f "./coverage/coverage-final.json" 55 | env: 56 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .dropbox 3 | *.tgz 4 | coverage/* 5 | .DS_Store 6 | .app.yml 7 | dist/* 8 | junit.xml 9 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "skipCI": true, 3 | "hooks": { 4 | "pre-commit": "lint-staged" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact§=true 2 | sign-git-commit=true 3 | sign-git-tag=true 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "arrowParens": "avoid", 6 | "bracketSpacing": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "all", 9 | "useTabs": false 10 | } 11 | -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | # sonar.host.url=https://sonarcloud.io 2 | # sonar.ws.timeout=10000 3 | 4 | # sonar.organization=walletpass 5 | # sonar.projectKey=walletpass_pass-js 6 | 7 | sonar.links.homepage=https://github.com/walletpass/pass-js 8 | sonar.links.issue=https://github.com/walletpass/pass-js/issues 9 | 10 | sonar.typescript.tsconfigPath=tsconfig.json 11 | 12 | # Encoding of the source code. Default is default system encoding 13 | sonar.sourceEncoding=UTF-8 14 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 15 | # This property is optional if sonar.modules is set. 16 | sonar.sources=src 17 | sonar.tests=__tests__ 18 | # We don't need to exclude it in sonar.sources because it is automatically taken care of 19 | sonar.test.inclusions=**/__tests__/*.ts 20 | 21 | # code duplication 22 | sonar.cpd.exclusions=**/__tests__/** 23 | 24 | # coverage 25 | sonar.coverage.exclusions=**/__tests__/** 26 | sonar.typescript.lcov.reportPaths=coverage/lcov.info 27 | 28 | # external reporters integration 29 | sonar.eslint.reportPaths=eslint-results.json 30 | # pending https://github.com/3dmind/jest-sonar-reporter/issues/30 31 | # sonar.testExecutionReportPaths=reports/test-reporter.xml 32 | sonar.junit.reportsPath=junit.xml 33 | 34 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "esbenp.prettier-vscode", 8 | "dbaeumer.vscode-eslint", 9 | "github.vscode-pull-request-github", 10 | "orta.vscode-jest", 11 | "mhcpnl.xcodestrings", 12 | "tomashubelbauer.zip-file-system" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "name": "vscode-jest-tests", 10 | "request": "launch", 11 | "args": ["--runInBand"], 12 | "cwd": "${workspaceFolder}", 13 | "console": "internalConsole", 14 | "internalConsoleOptions": "openOnSessionStart", 15 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 16 | "runtimeArgs": ["-r", "env-app-yaml/config"], 17 | "sourceMaps": true, 18 | "outFiles": ["dist"] 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "pass.json": "jsonc" 4 | }, 5 | "eslint.autoFixOnSave": true, 6 | "eslint.enable": true, 7 | "eslint.validate": [ 8 | { 9 | "language": "typescript", 10 | "autoFix": true 11 | } 12 | ], 13 | "typescript.tsdk": "node_modules/typescript/lib", 14 | "typescript.implementationsCodeLens.enabled": true, 15 | "typescript.preferences.importModuleSpecifier": "relative", 16 | "typescript.check.npmIsInstalled": true, 17 | "typescript.suggest.autoImports": false, 18 | "typescript.referencesCodeLens.enabled": true, 19 | "jest.runAllTestsFirst": false, 20 | "editor.formatOnSave": true, 21 | "editor.codeActionsOnSave": { 22 | "source.fixAll.eslint": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Destinations Transfers 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm (scoped)](https://img.shields.io/npm/v/@walletpass/pass-js.svg)](https://www.npmjs.com/package/@walletpass/pass-js) [![codecov](https://codecov.io/gh/walletpass/pass-js/branch/master/graph/badge.svg)](https://codecov.io/gh/walletpass/pass-js) 2 | [![Known Vulnerabilities](https://snyk.io/test/github/walletpass/pass-js/badge.svg?targetFile=package.json)](https://snyk.io/test/github/walletpass/pass-js?targetFile=package.json) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=walletpass_pass-js&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=walletpass_pass-js) [![tested with jest](https://img.shields.io/badge/tested_with-jest-99424f.svg)](https://github.com/facebook/jest) [![install size](https://packagephobia.now.sh/badge?p=@walletpass/pass-js)](https://packagephobia.now.sh/result?p=@walletpass/pass-js) 3 | 4 | 5 | Apple Wallet logo 6 | 7 | # @walletpass/pass-js 8 | 9 |

A Node.js library for generating Apple Wallet passes with localizations, NFC and web service push updates support. Written in Typescript.

10 | 11 |


12 | 13 | 14 | # Installation 15 | 16 | Install with `NPM` or `yarn`: 17 | ```sh 18 | npm install @walletpass/pass-js --save 19 | 20 | yarn add @walletpass/pass-js 21 | ``` 22 | 23 | 24 | 25 | # Get your certificates 26 | 27 | To start with, you'll need a certificate issued by [the iOS Provisioning 28 | Portal](https://developer.apple.com/ios/manage/passtypeids/index.action). You 29 | need one certificate per Pass Type ID. 30 | 31 | After adding this certificate to your Keychain, you need to export it as a 32 | `.p12` file first (go to Keychain Access, My Certificates and right-click to export), then convert that file into a `.pem` file using the `passkit-keys` command: 33 | 34 | ```sh 35 | ./bin/passkit-keys ./pathToKeysFolder 36 | ``` 37 | or openssl 38 | ```sh 39 | openssl pkcs12 -in .p12 -clcerts -out com.example.passbook.pem -passin pass: 40 | ``` 41 | 42 | and copy it into the keys directory. 43 | 44 | The [Apple Worldwide Developer Relations Certification 45 | Authority](https://www.apple.com/certificateauthority/) certificate is not needed anymore since it is already included in this package. 46 | 47 | # Start with a template 48 | 49 | Start with a template. A template has all the common data fields that will be 50 | shared between your passes. 51 | 52 | ```js 53 | const { Template } = require("@walletpass/pass-js"); 54 | 55 | // Create a Template from local folder, see __test__/resources/passes for examples 56 | // .load will load all fields from pass.json, 57 | // as well as all images and com.example.passbook.pem file as key 58 | // and localization string too 59 | const template = await Template.load( 60 | "./path/to/templateFolder", 61 | "secretKeyPasswod" 62 | ); 63 | 64 | // or 65 | // create a Template from a Buffer with ZIP content 66 | const s3 = new AWS.S3({ apiVersion: "2006-03-01", region: "us-west-2" }); 67 | const s3file = await s3 68 | .getObject({ 69 | Bucket: "bucket", 70 | Key: "pass-template.zip" 71 | }) 72 | .promise(); 73 | const template = await Template.fromBuffer(s3file.Body); 74 | 75 | // or create it manually 76 | const template = new Template("coupon", { 77 | passTypeIdentifier: "pass.com.example.passbook", 78 | teamIdentifier: "MXL", 79 | backgroundColor: "red", 80 | sharingProhibited: true 81 | }); 82 | await template.images.add("icon", iconPngFileBuffer) 83 | .add("logo", pathToLogoPNGfile) 84 | ``` 85 | 86 | The first argument is the pass style (`coupon`, `eventTicket`, etc), and the 87 | second optional argument has any fields you want to set on the template. 88 | 89 | You can access template fields directly, or from chained accessor methods, e.g: 90 | 91 | ```js 92 | template.passTypeIdentifier = "pass.com.example.passbook"; 93 | template.teamIdentifier = "MXL"; 94 | ``` 95 | 96 | The following template fields are required: 97 | 98 | - `passTypeIdentifier` - The Apple Pass Type ID, which has the prefix `pass.` 99 | - `teamIdentifier` - May contain an I 100 | 101 | You can set any available fields either on a template or pass instance, such as: `backgroundColor`, 102 | `foregroundColor`, `labelColor`, `logoText`, `organizationName`, 103 | `suppressStripShine` and `webServiceURL`. 104 | 105 | In addition, you need to tell the template where to find the key file: 106 | 107 | ```js 108 | await template.loadCertificate( 109 | "/etc/passbook/certificate_and_key.pem", 110 | "secret" 111 | ); 112 | // or set them as strings 113 | template.setCertificate(pemEncodedPassCertificate); 114 | template.setPrivateKey(pemEncodedPrivateKey, optionalKeyPassword); 115 | ``` 116 | 117 | If you have images that are common to all passes, you may want to specify them once in the template: 118 | 119 | ```js 120 | // specify a single image with specific density and localization 121 | await pass.images.add("icon", iconFilename, "2x", "ru"); 122 | // load all appropriate images in all densities and localizations 123 | await template.images.load("./images"); 124 | ``` 125 | 126 | You can add the image itself or a `Buffer`. Image format is enforced to be **PNG**. 127 | 128 | Alternatively, if you have one directory containing the template file `pass.json`, the key 129 | `com.example.passbook.pem` and all the needed images, you can just use this single command: 130 | 131 | ```js 132 | const template = await Template.load( 133 | "./path/to/templateFolder", 134 | "secretKeyPasswod" 135 | ); 136 | ``` 137 | 138 | You can use the options parameter of the template factory functions to set the `allowHttp` property. This enables you to use a `webServiceUrl` in your `pass.json` that uses the HTTP protocol instead of HTTPS for development purposes: 139 | 140 | ```js 141 | const template = await Template.load( 142 | "./path/to/templateFolder", 143 | "secretKeyPasswod", 144 | { 145 | allowHttp: true, 146 | }, 147 | ); 148 | ``` 149 | 150 | # Create your pass 151 | 152 | To create a new pass from a template: 153 | 154 | ```js 155 | const pass = template.createPass({ 156 | serialNumber: "123456", 157 | description: "20% off" 158 | }); 159 | ``` 160 | 161 | Just like the template, you can access pass fields directly, e.g: 162 | 163 | ```js 164 | pass.serialNumber = "12345"; 165 | pass.description = "20% off"; 166 | ``` 167 | 168 | In the JSON specification, structure fields (primary fields, secondary fields, 169 | etc) are represented as arrays, but items must have distinct key properties. Le 170 | sigh. 171 | 172 | To make it easier, you can use methods of standard Map object or `add` that 173 | will do the logical thing. For example, to add a primary field: 174 | 175 | ```js 176 | pass.primaryFields.add({ key: "time", label: "Time", value: "10:00AM" }); 177 | ``` 178 | 179 | To get one or all fields: 180 | 181 | ```js 182 | const dateField = pass.primaryFields.get("date"); 183 | for (const [key, { value }] of pass.primaryFields.entries()) { 184 | // ... 185 | } 186 | ``` 187 | 188 | To remove one or all fields: 189 | 190 | ```js 191 | pass.primaryFields.delete("date"); 192 | pass.primaryFields.clear(); 193 | ``` 194 | 195 | Adding images to a pass is the same as adding images to a template (see above). 196 | 197 | # Working with Dates 198 | If you have [dates in your fields](https://developer.apple.com/library/archive/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/FieldDictionary.html#//apple_ref/doc/uid/TP40012026-CH4-SW6) make sure they are in ISO 8601 format with timezone or a `Date` instance. 199 | For example: 200 | 201 | ```js 202 | const { constants } = require('@walletpass/pass-js'); 203 | 204 | pass.primaryFields.add({ key: "updated", label: "Updated at", value: new Date(), dateStyle: constants.dateTimeFormat.SHORT, timeStyle: constants.dateTimeFormat.SHORT }); 205 | 206 | // there is also a helper setDateTime method 207 | pass.auxiliaryFields.setDateTime( 208 | 'serviceDate', 209 | 'DATE', 210 | serviceMoment.toDate(), 211 | { 212 | dateStyle: constants.dateTimeFormat.MEDIUM, 213 | timeStyle: constants.dateTimeFormat.NONE, 214 | changeMessage: 'Service date changed to %@.', 215 | }, 216 | ); 217 | // main fields also accept Date objects 218 | pass.relevantDate = new Date(2020, 1, 1, 10, 0); 219 | template.expirationDate = new Date(2020, 10, 10, 10, 10); 220 | ``` 221 | 222 | # Localizations 223 | 224 | This library fully supports both [string localization](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html#//apple_ref/doc/uid/TP40012195-CH4-SW54) and/or [images localization](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html#//apple_ref/doc/uid/TP40012195-CH4-SW1): 225 | 226 | ```js 227 | // everything from template 228 | // will load all localized images and strings from folders like ru.lproj/ or fr-CA.lproj/ 229 | await template.load(folderPath); 230 | 231 | // Strings 232 | 233 | pass.localization 234 | .add("en-GB", { 235 | GATE: "GATE", 236 | DEPART: "DEPART", 237 | ARRIVE: "ARRIVE", 238 | SEAT: "SEAT", 239 | PASSENGER: "PASSENGER", 240 | FLIGHT: "FLIGHT" 241 | }) 242 | .add("ru", { 243 | GATE: "ВЫХОД", 244 | DEPART: "ВЫЛЕТ", 245 | ARRIVE: "ПРИЛЁТ", 246 | SEAT: "МЕСТО", 247 | PASSENGER: "ПАССАЖИР", 248 | FLIGHT: "РЕЙС" 249 | }); 250 | 251 | // Images 252 | 253 | await template.images.add( 254 | "logo" | "icon" | etc, 255 | imageFilePathOrBufferWithPNGdata, 256 | "1x" | "2x" | "3x" | undefined, 257 | "ru" 258 | ); 259 | ``` 260 | 261 | Localization applies for all fields' `label` and `value`. There is a note about that in [documentation](https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html). 262 | 263 | # Generate the file 264 | 265 | To generate a file: 266 | 267 | ```js 268 | const buf = await pass.asBuffer(); 269 | await fs.writeFile("pathToPass.pkpass", buf); 270 | ``` 271 | 272 | You can send the buffer directly to an HTTP server response: 273 | 274 | ```js 275 | app.use(async (ctx, next) => { 276 | ctx.status = 200; 277 | ctx.type = passkit.constants.PASS_MIME_TYPE; 278 | ctx.body = await pass.asBuffer(); 279 | }); 280 | ``` 281 | 282 | # Troubleshooting with Console app 283 | 284 | If the pass file generates without errors but you aren't able to open your pass on an iPhone, plug the iPhone into a Mac with macOS 10.14+ and open the 'Console' application. On the left, you can select your iPhone. You will then be able to inspect any errors that occur while adding the pass. 285 | 286 | ## Stay in touch 287 | 288 | * Author - [Konstantin Vyatkin](https://github.com/tinovyatkin) 289 | * Email - tino [at] vtkn.io 290 | 291 | ## License 292 | 293 | `@walletpass/pass-js` is [MIT licensed](LICENSE). 294 | 295 | # Financial Contributors 296 | 297 | Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/walletpass/contribute)] 298 | 299 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/images.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`PassImages should read localized images 1`] = ` 4 | Array [ 5 | "icon.png", 6 | "ru.lproj/logo.png", 7 | "ru.lproj/logo@2x.png", 8 | "zh-CN.lproj/logo.png", 9 | "zh-CN.lproj/logo@2x.png", 10 | ] 11 | `; 12 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/localizations.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Localizations files helpers returns string as utf-16 .lproj buffer 1`] = ` 4 | "\\"Terms of Service\\" = \\"Условия использования\\"; 5 | \\"Privacy Policy\\" = \\"Политика конфиденциальности\\"; 6 | \\"Legal Notices\\" = \\"Юридические уведомления\\"; 7 | \\"Unable to load webpage.\\" = \\"Не удалось загрузить веб-страницу.\\"; 8 | \\"OK\\" = \\"ОК\\"; 9 | \\"Do you want to add the token named “%@”?\\" = \\"Добавить токен \\\\\\"%@\\\\\\"?\\"; 10 | \\"No\\" = \\"Нет\\"; 11 | \\"Yes\\" = \\"Да\\"; 12 | \\"Invalid Key\\" = \\"Недействительный ключ\\"; 13 | \\"The key '%@' is invalid.\\" = \\"Ключ \\\\\\"%@\\\\\\" недействителен.\\"; 14 | \\"You must enter a key.\\" = \\"Необходимо ввести ключ.\\"; 15 | \\"Try Again\\" = \\"Повторить попытку\\"; 16 | \\"Cancel\\" = \\"Отмена\\"; 17 | \\"Invalid Barcode\\" = \\"Недопустимый штрихкод\\"; 18 | \\"The barcode '%@' is not a valid authentication token barcode.\\" = \\"Штрихкод \\\\\\"%@\\\\\\" не является действительным токеном аутентификации.\\"; 19 | \\"Welcome\\" = \\"Добро пожаловать\\"; 20 | \\"Welcome_label\\" = \\"Google Authenticator обеспечивает дополнительную безопасность аккаунта с помощью сгенерированных кодов.\\\\n\\\\nЧтобы использовать Google Authenticator, необходимо добавить аккаунт, нажав:\\"; 21 | \\"Legal Information\\" = \\"Юридическая информация\\"; 22 | \\"Authenticator\\" = \\"Authenticator\\"; 23 | \\"Google Authenticator\\" = \\"Google Authenticator\\"; 24 | \\"Add Token\\" = \\"Добавить токен\\"; 25 | \\"Time Based\\" = \\"По времени\\"; 26 | \\"Counter Based\\" = \\"По счетчику\\"; 27 | \\"Account:\\" = \\"Аккаунт:\\"; 28 | \\"user@example.com\\" = \\"user@example.com\\"; 29 | \\"Key:\\" = \\"Ключ:\\"; 30 | \\"Enter your key\\" = \\"Введите ключ\\"; 31 | \\"Scan Barcode\\" = \\"Сканировать штрихкод\\"; 32 | \\"Integrity Check Value\\" = \\"Integrity Check Value\\";" 33 | `; 34 | 35 | exports[`Localizations files helpers should clone other instance if provided for constructor 1`] = ` 36 | Array [ 37 | Object { 38 | "data": Object { 39 | "data": Array [ 40 | 255, 41 | 254, 42 | 34, 43 | 0, 44 | 107, 45 | 0, 46 | 101, 47 | 0, 48 | 121, 49 | 0, 50 | 49, 51 | 0, 52 | 34, 53 | 0, 54 | 32, 55 | 0, 56 | 61, 57 | 0, 58 | 32, 59 | 0, 60 | 34, 61 | 0, 62 | 66, 63 | 4, 64 | 53, 65 | 4, 66 | 65, 67 | 4, 68 | 66, 69 | 4, 70 | 34, 71 | 0, 72 | 59, 73 | 0, 74 | 10, 75 | 0, 76 | 34, 77 | 0, 78 | 107, 79 | 0, 80 | 101, 81 | 0, 82 | 121, 83 | 0, 84 | 50, 85 | 0, 86 | 34, 87 | 0, 88 | 32, 89 | 0, 90 | 61, 91 | 0, 92 | 32, 93 | 0, 94 | 34, 95 | 0, 96 | 116, 97 | 0, 98 | 101, 99 | 0, 100 | 115, 101 | 0, 102 | 116, 103 | 0, 104 | 32, 105 | 0, 106 | 107, 107 | 0, 108 | 101, 109 | 0, 110 | 121, 111 | 0, 112 | 32, 113 | 0, 114 | 50, 115 | 0, 116 | 34, 117 | 0, 118 | 59, 119 | 0, 120 | ], 121 | "type": "Buffer", 122 | }, 123 | "path": "ru.lproj/pass.strings", 124 | }, 125 | Object { 126 | "data": Object { 127 | "data": Array [ 128 | 255, 129 | 254, 130 | 34, 131 | 0, 132 | 107, 133 | 0, 134 | 101, 135 | 0, 136 | 121, 137 | 0, 138 | 49, 139 | 0, 140 | 34, 141 | 0, 142 | 32, 143 | 0, 144 | 61, 145 | 0, 146 | 32, 147 | 0, 148 | 34, 149 | 0, 150 | 116, 151 | 0, 152 | 101, 153 | 0, 154 | 115, 155 | 0, 156 | 116, 157 | 0, 158 | 32, 159 | 0, 160 | 102, 161 | 0, 162 | 114, 163 | 0, 164 | 32, 165 | 0, 166 | 107, 167 | 0, 168 | 101, 169 | 0, 170 | 121, 171 | 0, 172 | 49, 173 | 0, 174 | 34, 175 | 0, 176 | 59, 177 | 0, 178 | 10, 179 | 0, 180 | 34, 181 | 0, 182 | 107, 183 | 0, 184 | 101, 185 | 0, 186 | 121, 187 | 0, 188 | 50, 189 | 0, 190 | 34, 191 | 0, 192 | 32, 193 | 0, 194 | 61, 195 | 0, 196 | 32, 197 | 0, 198 | 34, 199 | 0, 200 | 116, 201 | 0, 202 | 101, 203 | 0, 204 | 115, 205 | 0, 206 | 116, 207 | 0, 208 | 32, 209 | 0, 210 | 102, 211 | 0, 212 | 114, 213 | 0, 214 | 32, 215 | 0, 216 | 107, 217 | 0, 218 | 101, 219 | 0, 220 | 121, 221 | 0, 222 | 50, 223 | 0, 224 | 34, 225 | 0, 226 | 59, 227 | 0, 228 | ], 229 | "type": "Buffer", 230 | }, 231 | "path": "fr.lproj/pass.strings", 232 | }, 233 | ] 234 | `; 235 | 236 | exports[`Localizations files helpers should read pass.strings file 1`] = ` 237 | Array [ 238 | Array [ 239 | "Terms of Service", 240 | "Условия использования", 241 | ], 242 | Array [ 243 | "Privacy Policy", 244 | "Политика конфиденциальности", 245 | ], 246 | Array [ 247 | "Legal Notices", 248 | "Юридические уведомления", 249 | ], 250 | Array [ 251 | "Unable to load webpage.", 252 | "Не удалось загрузить веб-страницу.", 253 | ], 254 | Array [ 255 | "OK", 256 | "ОК", 257 | ], 258 | Array [ 259 | "Do you want to add the token named “%@”?", 260 | "Добавить токен \\"%@\\"?", 261 | ], 262 | Array [ 263 | "No", 264 | "Нет", 265 | ], 266 | Array [ 267 | "Yes", 268 | "Да", 269 | ], 270 | Array [ 271 | "Invalid Key", 272 | "Недействительный ключ", 273 | ], 274 | Array [ 275 | "The key '%@' is invalid.", 276 | "Ключ \\"%@\\" недействителен.", 277 | ], 278 | Array [ 279 | "You must enter a key.", 280 | "Необходимо ввести ключ.", 281 | ], 282 | Array [ 283 | "Try Again", 284 | "Повторить попытку", 285 | ], 286 | Array [ 287 | "Cancel", 288 | "Отмена", 289 | ], 290 | Array [ 291 | "Invalid Barcode", 292 | "Недопустимый штрихкод", 293 | ], 294 | Array [ 295 | "The barcode '%@' is not a valid authentication token barcode.", 296 | "Штрихкод \\"%@\\" не является действительным токеном аутентификации.", 297 | ], 298 | Array [ 299 | "Welcome", 300 | "Добро пожаловать", 301 | ], 302 | Array [ 303 | "Welcome_label", 304 | "Google Authenticator обеспечивает дополнительную безопасность аккаунта с помощью сгенерированных кодов. 305 | 306 | Чтобы использовать Google Authenticator, необходимо добавить аккаунт, нажав:", 307 | ], 308 | Array [ 309 | "Legal Information", 310 | "Юридическая информация", 311 | ], 312 | Array [ 313 | "Authenticator", 314 | "Authenticator", 315 | ], 316 | Array [ 317 | "Google Authenticator", 318 | "Google Authenticator", 319 | ], 320 | Array [ 321 | "Add Token", 322 | "Добавить токен", 323 | ], 324 | Array [ 325 | "Time Based", 326 | "По времени", 327 | ], 328 | Array [ 329 | "Counter Based", 330 | "По счетчику", 331 | ], 332 | Array [ 333 | "Account:", 334 | "Аккаунт:", 335 | ], 336 | Array [ 337 | "user@example.com", 338 | "user@example.com", 339 | ], 340 | Array [ 341 | "Key:", 342 | "Ключ:", 343 | ], 344 | Array [ 345 | "Enter your key", 346 | "Введите ключ", 347 | ], 348 | Array [ 349 | "Scan Barcode", 350 | "Сканировать штрихкод", 351 | ], 352 | Array [ 353 | "Integrity Check Value", 354 | "Integrity Check Value", 355 | ], 356 | ] 357 | `; 358 | 359 | exports[`Localizations files helpers should read pass.strings file 2`] = ` 360 | Array [ 361 | Array [ 362 | "Terms of Service", 363 | "服务条款", 364 | ], 365 | Array [ 366 | "Privacy Policy", 367 | "隐私权政策", 368 | ], 369 | Array [ 370 | "Legal Notices", 371 | "法律声明", 372 | ], 373 | Array [ 374 | "Unable to load webpage.", 375 | "无法加载网页。", 376 | ], 377 | Array [ 378 | "OK", 379 | "确定", 380 | ], 381 | Array [ 382 | "Do you want to add the token named “%@”?", 383 | "您想添加名为“%@”的令牌吗?", 384 | ], 385 | Array [ 386 | "No", 387 | "否", 388 | ], 389 | Array [ 390 | "Yes", 391 | "是", 392 | ], 393 | Array [ 394 | "Invalid Key", 395 | "密钥无效", 396 | ], 397 | Array [ 398 | "The key '%@' is invalid.", 399 | "密钥“%@”无效。", 400 | ], 401 | Array [ 402 | "You must enter a key.", 403 | "您必须输入一个密钥。", 404 | ], 405 | Array [ 406 | "Try Again", 407 | "重试", 408 | ], 409 | Array [ 410 | "Cancel", 411 | "取消", 412 | ], 413 | Array [ 414 | "Invalid Barcode", 415 | "条形码无效", 416 | ], 417 | Array [ 418 | "The barcode '%@' is not a valid authentication token barcode.", 419 | "条形码“%@”不是有效的身份验证令牌条形码。", 420 | ], 421 | Array [ 422 | "Welcome", 423 | "欢迎", 424 | ], 425 | Array [ 426 | "Welcome_label", 427 | "Google 身份验证器可以生成验证码,从而增强登录时的安全性。 428 | 429 | 要使用 Google 身份验证器,必须先添加一个帐户,方法是按:", 430 | ], 431 | Array [ 432 | "Legal Information", 433 | "法律信息", 434 | ], 435 | Array [ 436 | "Authenticator", 437 | "身份验证器", 438 | ], 439 | Array [ 440 | "Google Authenticator", 441 | "Google 身份验证器", 442 | ], 443 | Array [ 444 | "Add Token", 445 | "添加令牌", 446 | ], 447 | Array [ 448 | "Time Based", 449 | "基于时间", 450 | ], 451 | Array [ 452 | "Counter Based", 453 | "基于计数器", 454 | ], 455 | Array [ 456 | "Account:", 457 | "帐户:", 458 | ], 459 | Array [ 460 | "user@example.com", 461 | "user@example.com", 462 | ], 463 | Array [ 464 | "Key:", 465 | "密钥:", 466 | ], 467 | Array [ 468 | "Enter your key", 469 | "输入您的密钥", 470 | ], 471 | Array [ 472 | "Scan Barcode", 473 | "扫描条形码", 474 | ], 475 | Array [ 476 | "Integrity Check Value", 477 | "Integrity Check Value", 478 | ], 479 | ] 480 | `; 481 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/pass.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generated should contain a manifest 1`] = ` 4 | Object { 5 | "icon.png": "e0f0bcd503f6117bce6a1a3ff8a68e36d26ae47f", 6 | "icon@2x.png": "10e4a72dbb02cc526cef967420553b459ccf2b9e", 7 | "logo.png": "abc97e3b2bc3b0e412ca4a853ba5fd90fe063551", 8 | "logo@2x.png": "87ca39ddc347646b5625062a349de4d3f06714ac", 9 | "pass.json": "e7be2b02b3921f19a43d2994c545a8b37e31a084", 10 | "strip.png": "68fc532d6c76e7c6c0dbb9b45165e62fbb8e9e32", 11 | "strip@2x.png": "17e4f5598362d21f92aa75bc66e2011a2310f48e", 12 | "thumbnail.png": "e199fc0e2839ad5698b206d5f4b7d8cb2418927c", 13 | "thumbnail@2x.png": "ac640c623741c0081fb1592d6353ebb03122244f", 14 | } 15 | `; 16 | 17 | exports[`generated should contain pass.json 1`] = ` 18 | Object { 19 | "coupon": Object { 20 | "headerFields": Array [ 21 | Object { 22 | "key": "date", 23 | "label": "Date", 24 | "value": "Nov 1", 25 | }, 26 | ], 27 | "primaryFields": Array [ 28 | Object { 29 | "key": "location", 30 | "label": "Place", 31 | "value": "High ground", 32 | }, 33 | ], 34 | }, 35 | "description": "20% of black roses", 36 | "formatVersion": 1, 37 | "labelColor": "rgb(255, 0, 0)", 38 | "organizationName": "Acme flowers", 39 | "passTypeIdentifier": "pass.com.example.passbook", 40 | "serialNumber": "123456", 41 | "teamIdentifier": "MXL", 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /__tests__/base-pass.ts: -------------------------------------------------------------------------------- 1 | import { PassBase } from '../src/lib/base-pass'; 2 | import { TOP_LEVEL_FIELDS } from '../src/constants'; 3 | import 'jest-extended'; 4 | 5 | describe('PassBase', () => { 6 | it('should have all required pass properties', () => { 7 | // to be able to check NFC property it must be storeCard 8 | const bp = new PassBase({ storeCard: {} }); 9 | for (const field in TOP_LEVEL_FIELDS) expect(bp).toHaveProperty(field); 10 | }); 11 | 12 | it('works with locations', () => { 13 | const bp = new PassBase(); 14 | expect(bp.locations).toBeUndefined(); 15 | bp.addLocation([1, 2]); 16 | bp.addLocation({ lat: 3, lng: 4 }, 'The point'); 17 | expect(bp.locations).toIncludeSameMembers([ 18 | { latitude: 2, longitude: 1 }, 19 | { latitude: 3, longitude: 4, relevantText: 'The point' }, 20 | ]); 21 | }); 22 | 23 | it('works with locations as setter', () => { 24 | const bp = new PassBase(); 25 | expect(bp.locations).toBeUndefined(); 26 | bp.locations = [ 27 | { longitude: 123, latitude: 321, relevantText: 'Test text' }, 28 | ]; 29 | expect(bp.locations).toIncludeSameMembers([ 30 | { longitude: 123, latitude: 321, relevantText: 'Test text' }, 31 | ]); 32 | }); 33 | 34 | it('works with beacons', () => { 35 | const bp = new PassBase(); 36 | bp.beacons = [{ proximityUUID: '1143243' }]; 37 | expect(bp.beacons).toHaveLength(1); 38 | expect(() => { 39 | bp.beacons = [{ byaka: 'buka' }]; 40 | }).toThrow(TypeError); 41 | }); 42 | 43 | it('webServiceURL', () => { 44 | const bp = new PassBase(); 45 | expect(() => { 46 | bp.webServiceURL = 'https://transfers.do/webservice'; 47 | }).not.toThrow(); 48 | expect(JSON.stringify(bp)).toMatchInlineSnapshot( 49 | `"{\\"formatVersion\\":1,\\"webServiceURL\\":\\"https://transfers.do/webservice\\"}"`, 50 | ); 51 | // should throw on bad url 52 | expect(() => { 53 | bp.webServiceURL = '/webservice'; 54 | }).toThrow(); 55 | 56 | const bpWithAllowHttpFalse = new PassBase({}, undefined, undefined, { 57 | allowHttp: false, 58 | }); 59 | expect(() => { 60 | bpWithAllowHttpFalse.webServiceURL = 'http://transfers.do/webservice'; 61 | }).toThrow(); 62 | 63 | const bpWithAllowHttpTrue = new PassBase({}, undefined, undefined, { 64 | allowHttp: true, 65 | }); 66 | expect(() => { 67 | bpWithAllowHttpTrue.webServiceURL = 'http://transfers.do/webservice'; 68 | }).not.toThrow(); 69 | }); 70 | 71 | it('color values as RGB triplets', () => { 72 | const bp = new PassBase(); 73 | expect(() => { 74 | bp.backgroundColor = 'rgb(125, 125,0)'; 75 | }).not.toThrow(); 76 | expect(() => { 77 | bp.labelColor = 'rgba(33, 344,3)'; 78 | }).toThrow(); 79 | expect(() => { 80 | bp.foregroundColor = 'rgb(33, 0,287)'; 81 | }).toThrow(); 82 | expect(() => { 83 | bp.stripColor = 'rgb(0, 0, 0)'; 84 | }).not.toThrow(); 85 | // should convert values to rgb 86 | bp.foregroundColor = 'white'; 87 | expect(bp.foregroundColor).toEqual([255, 255, 255]); 88 | // should convert values to rgb 89 | bp.foregroundColor = 'rgb(254, 254, 254)'; 90 | expect(bp.foregroundColor).toEqual([254, 254, 254]); 91 | bp.foregroundColor = '#FFF'; 92 | expect(bp.foregroundColor).toEqual([255, 255, 255]); 93 | bp.foregroundColor = 'rgba(0, 0, 255, 0.4)'; 94 | expect(bp.foregroundColor).toEqual([0, 0, 255]); 95 | bp.foregroundColor = 'rgb(0%, 0%, 100%)'; 96 | expect(bp.foregroundColor).toEqual([0, 0, 255]); 97 | bp.stripColor = 'black'; 98 | expect(bp.stripColor).toEqual([0, 0, 0]); 99 | // should throw on bad color 100 | expect(() => { 101 | bp.foregroundColor = 'byaka a ne color'; 102 | }).toThrow('Invalid color value'); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /__tests__/fieldsMap.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { FieldsMap } from '../src/lib/fieldsMap'; 4 | import { getW3CDateString } from '../src/lib/w3cdate'; 5 | 6 | test('FieldsMap Class', () => { 7 | const fields = new FieldsMap(); 8 | // should not add empty arrays if not needed 9 | expect(JSON.stringify({ a: 1, fields })).toBe('{"a":1}'); 10 | // add 11 | fields.add({ key: 'testKey', label: 'testLabel', value: 'testValue' }); 12 | expect(fields.get('testKey')).toMatchObject({ 13 | label: 'testLabel', 14 | value: 'testValue', 15 | }); 16 | expect(JSON.stringify({ zz: 'zz', theField: fields })).toBe( 17 | `{"zz":"zz","theField":[{"key":"testKey","label":"testLabel","value":"testValue"}]}`, 18 | ); 19 | // setValue 20 | fields.setValue('testKey', 'newValue'); 21 | expect(fields.get('testKey')).toMatchObject({ 22 | label: 'testLabel', 23 | value: 'newValue', 24 | }); 25 | // Add should replace the same key 26 | fields.add({ key: 'testKey', label: 'testLabel2', value: 'testValue2' }); 27 | expect(fields.get('testKey')).toMatchObject({ 28 | label: 'testLabel2', 29 | value: 'testValue2', 30 | }); 31 | // remove should remove the entry and whole key if it last one 32 | fields.delete('testKey'); 33 | expect(JSON.stringify({ b: 2, fields })).toBe('{"b":2}'); 34 | 35 | // setDateTime 36 | const date = new Date(); 37 | fields.setDateTime('testDate', 'labelDate', date); 38 | expect(JSON.stringify(fields)).toBe( 39 | `[{"key":"testDate","label":"labelDate","value":"${getW3CDateString( 40 | date, 41 | )}"}]`, 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /__tests__/get-geo-point.ts: -------------------------------------------------------------------------------- 1 | import { getGeoPoint } from '../src/lib/get-geo-point'; 2 | 3 | describe('getGeoPoint', () => { 4 | it('works with 4 numbers array', () => { 5 | expect(getGeoPoint([14.235, 23.3444, 23.4444])).toMatchObject({ 6 | longitude: expect.any(Number), 7 | latitude: expect.any(Number), 8 | altitude: expect.any(Number), 9 | }); 10 | }); 11 | 12 | it('throws on bad input', () => { 13 | expect(() => getGeoPoint([14.235, 'brrrr', 23.4444])).toThrow(); 14 | expect(() => getGeoPoint({ lat: 1, log: 3 })).toThrow( 15 | 'Unknown geo point format', 16 | ); 17 | }); 18 | 19 | it('works with lat/lng/alt object', () => { 20 | expect(getGeoPoint({ lat: 1, lng: 2, alt: 3 })).toMatchObject({ 21 | longitude: 2, 22 | latitude: 1, 23 | altitude: 3, 24 | }); 25 | }); 26 | 27 | it('work with longitude/latitude object', () => { 28 | expect(getGeoPoint({ longitude: 10, latitude: 20 })).toMatchObject({ 29 | longitude: 10, 30 | latitude: 20, 31 | altitude: undefined, 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /__tests__/images.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as path from 'path'; 4 | 5 | import { PassImages, IMAGE_FILENAME_REGEX } from '../src/lib/images'; 6 | 7 | describe('PassImages', () => { 8 | it('IMAGE_FILENAME_REGEX', () => { 9 | expect('logo.png').toMatch(IMAGE_FILENAME_REGEX); 10 | expect('Somefolder/logo.png').toMatch(IMAGE_FILENAME_REGEX); 11 | expect('byakablogo.png').not.toMatch(IMAGE_FILENAME_REGEX); 12 | expect('icon@2x.png').toMatch(IMAGE_FILENAME_REGEX); 13 | expect('thumbnail@3x.png').toMatch(IMAGE_FILENAME_REGEX); 14 | expect('logo@4x').not.toMatch(IMAGE_FILENAME_REGEX); 15 | expect('byaka.png').not.toMatch(IMAGE_FILENAME_REGEX); 16 | expect('logo.jpg').not.toMatch(IMAGE_FILENAME_REGEX); 17 | }); 18 | 19 | it('parseFilename', () => { 20 | const img = new PassImages(); 21 | expect(img.parseFilename('logo.png')).toEqual({ 22 | imageType: 'logo', 23 | }); 24 | expect(img.parseFilename('icon@2x.png')).toEqual({ 25 | imageType: 'icon', 26 | density: '2x', 27 | }); 28 | expect(img.parseFilename('logo.jpg')).toBeUndefined(); 29 | }); 30 | 31 | it('has class properties', () => { 32 | const img = new PassImages(); 33 | expect(img.load).toBeInstanceOf(Function); 34 | expect(img.add).toBeInstanceOf(Function); 35 | }); 36 | 37 | it('reads all images from directory without localized images', async () => { 38 | const img = new PassImages(); 39 | const imgDir = path.resolve(__dirname, '../images/'); 40 | await img.load(imgDir); 41 | expect(img.size).toBe(18); 42 | }); 43 | 44 | it('should read localized images', async () => { 45 | const img = new PassImages(); 46 | const imgDir = path.resolve(__dirname, './resources/passes/Generic'); 47 | await img.load(imgDir); 48 | expect(img.size).toBe(5); 49 | const arr = await img.toArray(); 50 | expect(arr).toBeInstanceOf(Array); 51 | expect(arr.map(f => f.path).sort()).toMatchSnapshot(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /__tests__/localizations.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-shadow */ 2 | import * as path from 'path'; 3 | import { TextDecoder } from 'util'; 4 | import { execFileSync } from 'child_process'; 5 | import { randomBytes } from 'crypto'; 6 | import { unlinkSync, mkdtempSync, writeFileSync } from 'fs'; 7 | import { tmpdir } from 'os'; 8 | 9 | import * as Localization from '../src/lib/localizations'; 10 | 11 | const RU_STRINGS_FILE = path.resolve( 12 | __dirname, 13 | './resources/passes/Generic/ru.lproj/pass.strings', 14 | ); 15 | const ZH_STRINGS_FILE = path.resolve( 16 | __dirname, 17 | './resources/passes/Generic/zh_CN.lproj/pass.strings', 18 | ); 19 | 20 | describe('Localizations files helpers', () => { 21 | it('escapeString -> unescape', () => { 22 | const str = `This is "multiline" string', 23 | with some rare character like \\ and \\\\ 😜 inside it`; 24 | expect(Localization.unescapeString(Localization.escapeString(str))).toBe( 25 | str, 26 | ); 27 | }); 28 | 29 | if (process.platform === 'darwin') { 30 | it('string fixtures files must pass plutil -lint', async () => { 31 | const stdout = execFileSync( 32 | 'plutil', 33 | ['-lint', RU_STRINGS_FILE, ZH_STRINGS_FILE], 34 | { 35 | encoding: 'utf8', 36 | }, 37 | ); 38 | expect(stdout.trim().split(/\n/)).toEqual( 39 | expect.arrayContaining([expect.toEndWith(': OK')]), 40 | ); 41 | }); 42 | } 43 | it('should read pass.strings file', async () => { 44 | const resRu = await Localization.readLprojStrings(RU_STRINGS_FILE); 45 | expect([...resRu]).toMatchSnapshot(); 46 | const resZh = await Localization.readLprojStrings(ZH_STRINGS_FILE); 47 | expect([...resZh]).toMatchSnapshot(); 48 | }); 49 | 50 | it('returns string as utf-16 .lproj buffer', async () => { 51 | const resRu = await Localization.readLprojStrings(RU_STRINGS_FILE); 52 | const buf = Localization.getLprojBuffer(resRu); 53 | expect(Buffer.isBuffer(buf)).toBeTruthy(); 54 | const decoder = new TextDecoder('utf-16', { fatal: true }); 55 | expect(decoder.decode(buf)).toMatchSnapshot(); 56 | }); 57 | 58 | if (process.platform === 'darwin') { 59 | it('output buffer passes plutil -lint', async () => { 60 | const resZh = await Localization.readLprojStrings(ZH_STRINGS_FILE); 61 | const tmd = mkdtempSync(`${tmpdir()}${path.sep}`); 62 | const stringsFileName = path.join( 63 | tmd, 64 | `pass-${randomBytes(10).toString('hex')}.strings`, 65 | ); 66 | writeFileSync(stringsFileName, Localization.getLprojBuffer(resZh)); 67 | 68 | const stdout = execFileSync('plutil', ['-lint', stringsFileName], { 69 | encoding: 'utf8', 70 | }); 71 | unlinkSync(stringsFileName); 72 | expect(stdout.trim()).toEndWith(': OK'); 73 | }); 74 | } 75 | 76 | it('loads localizations from files', async () => { 77 | const loc = new Localization.Localizations(); 78 | await loc.load(path.resolve(__dirname, './resources/passes/Generic')); 79 | expect(loc.size).toBe(2); 80 | // ensure it normalizes locale 81 | expect(loc.has('zh-CN')).toBeTruthy(); 82 | }); 83 | 84 | it('read -> write -> compare', async () => { 85 | const resRu = await Localization.readLprojStrings(RU_STRINGS_FILE); 86 | const tmd = mkdtempSync(`${tmpdir()}${path.sep}`); 87 | const stringsFileName = path.join( 88 | tmd, 89 | `pass-${randomBytes(10).toString('hex')}.strings`, 90 | ); 91 | writeFileSync(stringsFileName, Localization.getLprojBuffer(resRu)); 92 | 93 | const resRu2 = await Localization.readLprojStrings(stringsFileName); 94 | 95 | // If we on macOs we can't miss an opportunity to plutil -lint 96 | if (process.platform === 'darwin') { 97 | const stdout = execFileSync('plutil', ['-lint', stringsFileName], { 98 | encoding: 'utf8', 99 | }); 100 | expect(stdout.trim()).toEndWith(': OK'); 101 | } 102 | unlinkSync(stringsFileName); 103 | expect([...resRu]).toIncludeSameMembers([...resRu2]); 104 | }); 105 | 106 | it('should clone other instance if provided for constructor', () => { 107 | const loc1 = new Localization.Localizations(); 108 | loc1 109 | .add('ru', { key1: 'test key 1', key2: 'test key 2' }) 110 | .add('fr', { key1: 'test fr key1', key2: 'test fr key2' }); 111 | const loc2 = new Localization.Localizations(loc1); 112 | expect(loc2.size).toBe(2); 113 | expect(loc2.get('fr').get('key2')).toBe('test fr key2'); 114 | // modify a key in original loc1 115 | loc1.get('ru').set('key1', 'тест'); 116 | expect(loc2.get('ru').get('key1')).toBe('test key 1'); 117 | expect(loc1.toArray()).toMatchSnapshot(); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /__tests__/nfc-field.ts: -------------------------------------------------------------------------------- 1 | import * as forge from 'node-forge'; 2 | 3 | import { NFCField } from '../src/lib/nfc-fields'; 4 | 5 | /** 6 | * @see {@link https://stackoverflow.com/questions/48438753/apple-wallet-nfc-encryptionpublickey} 7 | */ 8 | const TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- 9 | MDkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDIgADLFuyM4y1GV0CxWhs35qxxk+ob4Mz 10 | Tm6EWP0OZQBdErU= 11 | -----END PUBLIC KEY-----`; 12 | 13 | describe('NFCField', () => { 14 | it('returns undefined for JSON stringify', () => { 15 | const n = new NFCField(); 16 | expect(JSON.stringify(n)).toBeUndefined(); 17 | }); 18 | 19 | it('decodes public key', () => { 20 | const nfc = new NFCField( 21 | { 22 | message: 'test message', 23 | encryptionPublicKey: TEST_PUBLIC_KEY.replace(/\n/g, '') 24 | .replace('-----BEGIN PUBLIC KEY-----', '') 25 | .replace('-----END PUBLIC KEY-----', ''), 26 | }, 27 | ); 28 | expect(nfc.toJSON()).toEqual( 29 | { 30 | message: nfc.message, 31 | encryptionPublicKey: nfc.encryptionPublicKey 32 | }); 33 | }); 34 | 35 | it('add Base64 encoded public key', () => { 36 | const nfc = new NFCField(); 37 | nfc.message = 'hello world'; 38 | nfc.setPublicKey(TEST_PUBLIC_KEY); 39 | expect(nfc.toJSON()).toEqual( 40 | { 41 | encryptionPublicKey: TEST_PUBLIC_KEY.replace(/\n/g, '') 42 | .replace('-----BEGIN PUBLIC KEY-----', '') 43 | .replace('-----END PUBLIC KEY-----', ''), 44 | message: 'hello world', 45 | }, 46 | ); 47 | }); 48 | 49 | it('throws on wrong algorithm public key or no public key', () => { 50 | const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048, e: 0x10001 }); 51 | const privatePem = forge.pki.privateKeyToPem(keypair.privateKey); 52 | const publicPem = forge.pki.publicKeyToPem(keypair.publicKey); 53 | const nfc = new NFCField(); 54 | expect(() => nfc.setPublicKey(privatePem)).toThrow(/SubjectPublicKeyInfo/); 55 | expect(() => nfc.setPublicKey(publicPem)).toThrow(/ECDH public key/); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /__tests__/normalize-locale.ts: -------------------------------------------------------------------------------- 1 | import { normalizeLocale } from '../src/lib/normalize-locale'; 2 | 3 | describe('normalizeLocale', () => { 4 | it('normalizes everything good', () => { 5 | expect(normalizeLocale('de')).toBe('de'); 6 | expect(normalizeLocale('en_US')).toBe('en-US'); 7 | expect(normalizeLocale('zh_Hant-TW')).toBe('zh-Hant-TW'); 8 | expect(normalizeLocale('En-au')).toBe('en-AU'); 9 | expect(normalizeLocale('aZ_cYrl-aZ')).toBe('az-Cyrl-AZ'); 10 | }); 11 | 12 | it('throws on non-locale', () => { 13 | expect(() => normalizeLocale('en-Byakabukabubbe')).toThrow(TypeError); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /__tests__/pass.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { createHash, randomBytes } from 'crypto'; 4 | import { unlinkSync, mkdtempSync, writeFileSync, readFileSync } from 'fs'; 5 | import { tmpdir } from 'os'; 6 | import * as path from 'path'; 7 | import { execFileSync } from 'child_process'; 8 | 9 | import * as constants from '../src/constants'; 10 | import { Template } from '../src/template'; 11 | 12 | // Clone all the fields in object, except the named field, and return a new 13 | // object. 14 | // 15 | // object - Object to clone 16 | // field - Except this field 17 | function cloneExcept(object, field) { 18 | const clone = {}; 19 | for (const key in object) { 20 | if (key !== field) clone[key] = object[key]; 21 | } 22 | return clone; 23 | } 24 | 25 | function unzip(zipFile, filename): Buffer { 26 | return execFileSync('unzip', ['-p', zipFile, filename], { 27 | encoding: 'buffer', 28 | }); 29 | } 30 | 31 | const template = new Template('coupon', { 32 | passTypeIdentifier: 'pass.com.example.passbook', 33 | teamIdentifier: 'MXL', 34 | labelColor: 'red', 35 | }); 36 | 37 | const fields = { 38 | serialNumber: '123456', 39 | organizationName: 'Acme flowers', 40 | description: '20% of black roses', 41 | }; 42 | 43 | describe('Pass', () => { 44 | beforeAll(async () => { 45 | template.setCertificate(process.env.APPLE_PASS_CERTIFICATE); 46 | template.setPrivateKey( 47 | process.env.APPLE_PASS_PRIVATE_KEY, 48 | process.env.APPLE_PASS_KEY_PASSWORD, 49 | ); 50 | }); 51 | it('from template', () => { 52 | const pass = template.createPass(); 53 | 54 | // should copy template fields 55 | expect(pass.passTypeIdentifier).toBe('pass.com.example.passbook'); 56 | 57 | // should start with no images 58 | expect(pass.images.size).toBe(0); 59 | 60 | // should create a structure based on style 61 | expect(pass.style).toBe('coupon'); 62 | // labelColor is array of 3 numbers 63 | expect(pass.labelColor).toBeInstanceOf(Array); 64 | expect(pass.labelColor).toHaveLength(3); 65 | // but it stringifies to rgb string and Jest is using stringify for equality 66 | expect(JSON.stringify(pass.labelColor)).toStrictEqual('"rgb(255, 0, 0)"'); 67 | }); 68 | 69 | // 70 | it('barcodes as Array', () => { 71 | const pass = template.createPass(cloneExcept(fields, 'serialNumber')); 72 | expect(() => { 73 | pass.barcodes = [ 74 | { 75 | format: 'PKBarcodeFormatQR', 76 | message: 'Barcode message', 77 | messageEncoding: 'iso-8859-1', 78 | }, 79 | ]; 80 | }).not.toThrow(); 81 | expect(() => { 82 | pass.barcodes = 'byaka'; 83 | }).toThrow(); 84 | }); 85 | 86 | it('without serial number should not be valid', () => { 87 | const pass = template.createPass(cloneExcept(fields, 'serialNumber')); 88 | expect(() => pass.validate()).toThrow('serialNumber is required in a Pass'); 89 | }); 90 | 91 | it('without organization name should not be valid', () => { 92 | const pass = template.createPass(cloneExcept(fields, 'organizationName')); 93 | expect(() => pass.validate()).toThrow( 94 | 'organizationName is required in a Pass', 95 | ); 96 | }); 97 | 98 | it('without description should not be valid', () => { 99 | const pass = template.createPass(cloneExcept(fields, 'description')); 100 | expect(() => pass.validate()).toThrow('description is required in a Pass'); 101 | }); 102 | 103 | it('without icon.png should not be valid', () => { 104 | const pass = template.createPass(fields); 105 | expect(() => pass.validate()).toThrow('Missing required image icon.png'); 106 | }); 107 | 108 | it('without logo.png should not be valid', async () => { 109 | const pass = template.createPass(fields); 110 | await pass.images.add( 111 | 'icon', 112 | readFileSync(path.resolve(__dirname, './resources/icon.png')), 113 | undefined, 114 | 'en-US', 115 | ); 116 | 117 | expect.assertions(1); 118 | try { 119 | await pass.asBuffer(); 120 | } catch (err) { 121 | expect(err).toHaveProperty('message', 'Missing required image logo.png'); 122 | } 123 | }); 124 | 125 | it('boarding pass has string-only property in structure fields', async () => { 126 | const templ = await Template.load( 127 | path.resolve(__dirname, './resources/passes/BoardingPass.pass/'), 128 | ); 129 | expect(templ.style).toBe('boardingPass'); 130 | // ensures it parsed color read from JSON 131 | expect(templ.backgroundColor).toEqual([50, 91, 185]); 132 | // ensure it parses well fields 133 | expect(templ.backFields.size).toBe(2); 134 | expect(templ.auxiliaryFields.size).toBe(4); 135 | expect(templ.relevantDate).toBeValidDate(); 136 | expect(templ.relevantDate.getFullYear()).toBe(2012); 137 | expect(templ.barcodes).toEqual( 138 | expect.arrayContaining([ 139 | expect.objectContaining({ message: expect.toBeString() }), 140 | ]), 141 | ); 142 | // switching transit type 143 | const pass = templ.createPass(); 144 | expect(pass.transitType).toBe(constants.TRANSIT.AIR); 145 | pass.transitType = constants.TRANSIT.BUS; 146 | expect(pass.transitType).toBe(constants.TRANSIT.BUS); 147 | expect(pass.toJSON()).toMatchObject({ 148 | boardingPass: expect.objectContaining({ 149 | transitType: constants.TRANSIT.BUS, 150 | }), 151 | }); 152 | }); 153 | 154 | it('should convert back to the same pass.json', async () => { 155 | const t = await Template.load( 156 | path.resolve(__dirname, './resources/passes/Event.pass'), 157 | ); 158 | expect(JSON.parse(JSON.stringify(t))).toEqual( 159 | require('./resources/passes/Event.pass/pass.json'), 160 | ); 161 | }); 162 | 163 | it('asBuffer returns buffer with ZIP file', async () => { 164 | const pass = template.createPass(fields); 165 | await pass.images.load(path.resolve(__dirname, './resources')); 166 | pass.headerFields.add({ key: 'date', value: 'Date', label: 'Nov 1' }); 167 | pass.primaryFields.add({ 168 | key: 'location', 169 | label: 'Place', 170 | value: 'High ground', 171 | }); 172 | const tmd = mkdtempSync(`${tmpdir()}${path.sep}`); 173 | const passFileName = path.join( 174 | tmd, 175 | `pass-${randomBytes(10).toString('hex')}.pkpass`, 176 | ); 177 | const buf = await pass.asBuffer(); 178 | expect(Buffer.isBuffer(buf)).toBeTruthy(); 179 | writeFileSync(passFileName, buf); 180 | // test that result is valid ZIP at least 181 | const stdout = execFileSync('unzip', ['-t', passFileName], { 182 | encoding: 'utf8', 183 | }); 184 | unlinkSync(passFileName); 185 | expect(stdout).toContain('No errors detected in compressed data'); 186 | }); 187 | }); 188 | 189 | describe('generated', () => { 190 | const tmd = mkdtempSync(`${tmpdir()}${path.sep}`); 191 | const passFileName = path.join( 192 | tmd, 193 | `pass-${randomBytes(10).toString('hex')}.pkpass`, 194 | ); 195 | 196 | beforeAll(async () => { 197 | jest.setTimeout(100000); 198 | const pass = template.createPass(fields); 199 | await pass.images.load(path.resolve(__dirname, './resources')); 200 | pass.headerFields.add({ key: 'date', label: 'Date', value: 'Nov 1' }); 201 | pass.primaryFields.add({ 202 | key: 'location', 203 | label: 'Place', 204 | value: 'High ground', 205 | }); 206 | expect(pass.images.size).toBe(8); 207 | writeFileSync(passFileName, await pass.asBuffer()); 208 | }); 209 | 210 | afterAll(async () => { 211 | unlinkSync(passFileName); 212 | }); 213 | 214 | it('should be a valid ZIP', async () => { 215 | const stdout = execFileSync('unzip', ['-t', passFileName], { 216 | encoding: 'utf8', 217 | }); 218 | expect(stdout).toContain('No errors detected in compressed data'); 219 | }); 220 | 221 | it('should contain pass.json', async () => { 222 | const res = JSON.parse(unzip(passFileName, 'pass.json').toString('utf8')); 223 | expect(res).toMatchSnapshot(); 224 | }); 225 | 226 | it('should contain a manifest', async () => { 227 | const res = JSON.parse( 228 | unzip(passFileName, 'manifest.json').toString('utf8'), 229 | ); 230 | expect(res).toMatchSnapshot(); 231 | }); 232 | 233 | // this test depends on MacOS specific signpass, so, run only on MacOS 234 | if (process.platform === 'darwin') { 235 | it('should contain a signature', async () => { 236 | const stdout = execFileSync( 237 | path.resolve(__dirname, './resources/bin/signpass'), 238 | ['-v', passFileName], 239 | { encoding: 'utf8' }, 240 | ); 241 | expect(stdout).toContain('*** SUCCEEDED ***'); 242 | }); 243 | } 244 | 245 | it('should contain the icon', async () => { 246 | const buffer = unzip(passFileName, 'icon.png'); 247 | expect( 248 | createHash('sha1') 249 | .update(buffer) 250 | .digest('hex'), 251 | ).toBe('e0f0bcd503f6117bce6a1a3ff8a68e36d26ae47f'); 252 | }); 253 | 254 | it('should contain the logo', async () => { 255 | const buffer = unzip(passFileName, 'logo.png'); 256 | expect( 257 | createHash('sha1') 258 | .update(buffer) 259 | .digest('hex'), 260 | ).toBe('abc97e3b2bc3b0e412ca4a853ba5fd90fe063551'); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /__tests__/resources/bin/signpass: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/bin/signpass -------------------------------------------------------------------------------- /__tests__/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/icon.png -------------------------------------------------------------------------------- /__tests__/resources/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/icon@2x.png -------------------------------------------------------------------------------- /__tests__/resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/logo.png -------------------------------------------------------------------------------- /__tests__/resources/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/logo@2x.png -------------------------------------------------------------------------------- /__tests__/resources/passes/BoardingPass.pass/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/BoardingPass.pass/icon.png -------------------------------------------------------------------------------- /__tests__/resources/passes/BoardingPass.pass/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/BoardingPass.pass/icon@2x.png -------------------------------------------------------------------------------- /__tests__/resources/passes/BoardingPass.pass/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/BoardingPass.pass/logo.png -------------------------------------------------------------------------------- /__tests__/resources/passes/BoardingPass.pass/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/BoardingPass.pass/logo@2x.png -------------------------------------------------------------------------------- /__tests__/resources/passes/BoardingPass.pass/pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "passTypeIdentifier": "pass.com.apple.devpubs.example", 4 | "serialNumber": "gT6zrHkaW", 5 | "teamIdentifier": "A93A5CM278", 6 | "webServiceURL": "https://example.com/passes/", 7 | "authenticationToken": "vxwxd7J8AlNNFPS8k0a0FfUFtq0ewzFdc", 8 | // that should be restored into a Date object 9 | "relevantDate": "2012-07-22T14:25-08:00", 10 | "locations": [ 11 | { 12 | "longitude": -122.3748889, 13 | "latitude": 37.6189722 14 | } 15 | ], 16 | "barcodes": [ 17 | { 18 | "message": "SFOJFK JOHN APPLESEED LH451 2012-07-22T14:25-08:00", 19 | "format": "PKBarcodeFormatPDF417", 20 | "messageEncoding": "iso-8859-1" 21 | } 22 | ], 23 | "organizationName": "Skyport Airways", 24 | "description": "Skyport Boarding Pass", 25 | "logoText": "Skyport Airways", 26 | "foregroundColor": "rgb(22, 55, 110)", 27 | "backgroundColor": "rgb(50, 91, 185)", 28 | "boardingPass": { 29 | "transitType": "PKTransitTypeAir", 30 | "headerFields": [ 31 | { 32 | "label": "GATE", 33 | "key": "gate", 34 | "value": "23", 35 | "changeMessage": "Gate changed to %@." 36 | } 37 | ], 38 | "primaryFields": [ 39 | { 40 | "key": "depart", 41 | "label": "SAN FRANCISCO", 42 | "value": "SFO" 43 | }, 44 | { 45 | "key": "arrive", 46 | "label": "NEW YORK", 47 | "value": "JFK" 48 | } 49 | ], 50 | "secondaryFields": [ 51 | { 52 | "key": "passenger", 53 | "label": "PASSENGER", 54 | "value": "John Appleseed" 55 | } 56 | ], 57 | "auxiliaryFields": [ 58 | { 59 | "label": "DEPART", 60 | "key": "boardingTime", 61 | "value": "2:25 PM", 62 | "changeMessage": "Boarding time changed to %@." 63 | }, 64 | { 65 | "label": "FLIGHT", 66 | "key": "flightNewName", 67 | "value": "815", 68 | "changeMessage": "Flight number changed to %@" 69 | }, 70 | { 71 | "key": "class", 72 | "label": "DESIG.", 73 | "value": "Coach" 74 | }, 75 | { 76 | "key": "date", 77 | "label": "DATE", 78 | "value": "7/22" 79 | } 80 | ], 81 | "backFields": [ 82 | { 83 | "key": "passport", 84 | "label": "PASSPORT", 85 | "value": "Canadian/Canadien" 86 | }, 87 | { 88 | "key": "residence", 89 | "label": "RESIDENCE", 90 | "value": "999 Infinite Loop, Apartment 42, Cupertino CA" 91 | } 92 | ] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /__tests__/resources/passes/Event.pass/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Event.pass/background.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Event.pass/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Event.pass/background@2x.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Event.pass/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Event.pass/icon.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Event.pass/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Event.pass/icon@2x.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Event.pass/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Event.pass/logo.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Event.pass/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Event.pass/logo@2x.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Event.pass/pass.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "passTypeIdentifier": "pass.com.apple.devpubs.example", 4 | "serialNumber": "nmyuxofgna", 5 | "teamIdentifier": "A93A5CM278", 6 | "webServiceURL": "https://example.com/passes/", 7 | "authenticationToken": "vxwxd7J8AlNNFPS8k0a0FfUFtq0ewzFdc", 8 | "relevantDate": "2011-12-08T13:00-04:00", 9 | "locations": [ 10 | { 11 | "longitude": -122.3748889, 12 | "latitude": 37.6189722 13 | }, 14 | { 15 | "longitude": -122.03118, 16 | "latitude": 37.33182 17 | } 18 | ], 19 | "barcodes": [ 20 | { 21 | "message": "123456789", 22 | "format": "PKBarcodeFormatPDF417", 23 | "messageEncoding": "iso-8859-1" 24 | } 25 | ], 26 | "organizationName": "Apple Inc.", 27 | "description": "Apple Event Ticket", 28 | "foregroundColor": "rgb(255, 255, 255)", 29 | "backgroundColor": "rgb(60, 65, 76)", 30 | "eventTicket": { 31 | "primaryFields": [ 32 | { 33 | "key": "event", 34 | "label": "EVENT", 35 | "value": "The Beat Goes On" 36 | } 37 | ], 38 | "secondaryFields": [ 39 | { 40 | "key": "loc", 41 | "label": "LOCATION", 42 | "value": "Moscone West" 43 | } 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /__tests__/resources/passes/Event.pass/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Event.pass/thumbnail.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Event.pass/thumbnail@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Event.pass/thumbnail@2x.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Generic.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Generic.zip -------------------------------------------------------------------------------- /__tests__/resources/passes/Generic/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Generic/icon.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Generic/ru.lproj/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Generic/ru.lproj/logo.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Generic/ru.lproj/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Generic/ru.lproj/logo@2x.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Generic/ru.lproj/pass.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Generic/ru.lproj/pass.strings -------------------------------------------------------------------------------- /__tests__/resources/passes/Generic/zh_CN.lproj/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Generic/zh_CN.lproj/logo.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Generic/zh_CN.lproj/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Generic/zh_CN.lproj/logo@2x.png -------------------------------------------------------------------------------- /__tests__/resources/passes/Generic/zh_CN.lproj/pass.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/Generic/zh_CN.lproj/pass.strings -------------------------------------------------------------------------------- /__tests__/resources/passes/StoreCard.pkpass: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/passes/StoreCard.pkpass -------------------------------------------------------------------------------- /__tests__/resources/strip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/strip.png -------------------------------------------------------------------------------- /__tests__/resources/strip@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/strip@2x.png -------------------------------------------------------------------------------- /__tests__/resources/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/thumbnail.png -------------------------------------------------------------------------------- /__tests__/resources/thumbnail@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/__tests__/resources/thumbnail@2x.png -------------------------------------------------------------------------------- /__tests__/signManifest.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { randomBytes } from 'crypto'; 4 | 5 | import { signManifest } from '../src/lib/signManifest-forge'; 6 | import { Template } from '../src/template'; 7 | 8 | const TEST_STRING = randomBytes(1024).toString('base64'); 9 | 10 | test('signManifest', async () => { 11 | // creating template to load certificate and key 12 | const template = new Template('generic'); 13 | template.setCertificate(process.env.APPLE_PASS_CERTIFICATE); 14 | template.setPrivateKey( 15 | process.env.APPLE_PASS_PRIVATE_KEY, 16 | process.env.APPLE_PASS_KEY_PASSWORD, 17 | ); 18 | 19 | const jsSignedBuffer = await signManifest( 20 | template.certificate, 21 | template.key, 22 | TEST_STRING, 23 | ); 24 | expect(Buffer.isBuffer(jsSignedBuffer)).toBeTruthy(); 25 | }); 26 | -------------------------------------------------------------------------------- /__tests__/template.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { readFileSync } from 'fs'; 3 | 4 | import { Template } from '../src/template'; 5 | 6 | const originalFields = { 7 | passTypeIdentifier: 'com.example.passbook', 8 | }; 9 | 10 | describe('Template', () => { 11 | it('should throw an error on unsupported type', () => { 12 | // @ts-ignore 13 | expect(() => new Template('discount')).toThrow(); 14 | }); 15 | 16 | it('doesn`t mutate fields', () => { 17 | const templ = new Template('coupon', originalFields); 18 | expect(templ.passTypeIdentifier).toBe('com.example.passbook'); 19 | templ.passTypeIdentifier = 'com.byaka.buka'; 20 | expect(templ.passTypeIdentifier).toBe('com.byaka.buka'); 21 | expect(originalFields.passTypeIdentifier).toBe('com.example.passbook'); 22 | 23 | // should not change when original object changes' 24 | originalFields.passTypeIdentifier = 'com.example.somethingelse'; 25 | expect(templ.passTypeIdentifier).toBe('com.byaka.buka'); 26 | }); 27 | 28 | it('loading template from a folder', async () => { 29 | const templ = await Template.load( 30 | path.resolve(__dirname, './resources/passes/BoardingPass.pass'), 31 | ); 32 | expect(templ.passTypeIdentifier).toBe('pass.com.apple.devpubs.example'); 33 | expect(templ.images.size).toBe(4); 34 | 35 | const templ2 = await Template.load( 36 | path.resolve(__dirname, './resources/passes/Event.pass'), 37 | ); 38 | expect(templ2.teamIdentifier).toBe('A93A5CM278'); 39 | expect(templ2.images.size).toBe(8); 40 | }); 41 | 42 | it('loads images and translation from folder without pass.json', async () => { 43 | const templ = await Template.load( 44 | path.resolve(__dirname, './resources/passes/Generic'), 45 | ); 46 | expect(templ.images.size).toBe(5); 47 | expect(templ.localization.size).toBe(2); 48 | // ensure it normalizes locales name 49 | expect(templ.localization.has('zh-CN')).toBeTruthy(); 50 | }); 51 | 52 | it('loads template from ZIP buffer', async () => { 53 | const buffer = readFileSync( 54 | path.resolve(__dirname, './resources/passes/Generic.zip'), 55 | ); 56 | const res = await Template.fromBuffer(buffer); 57 | expect(res).toBeInstanceOf(Template); 58 | expect(res.images.size).toBe(8); 59 | expect(res.localization.size).toBe(3); 60 | expect(res.localization.get('zh-CN').size).toBe(29); 61 | }); 62 | 63 | it('can load existing pass as Template', async () => { 64 | const buffer = readFileSync( 65 | path.resolve(__dirname, './resources/passes/StoreCard.pkpass'), 66 | ); 67 | const res = await Template.fromBuffer(buffer); 68 | expect(res).toBeInstanceOf(Template); 69 | expect(res.passTypeIdentifier).toBe('pass.com.apple.devpubs.example'); 70 | expect(res.images.size).toBe(5); 71 | }); 72 | 73 | it('push updates', async () => { 74 | const template = new Template('coupon', { 75 | passTypeIdentifier: 'pass.com.example.passbook', 76 | teamIdentifier: 'MXL', 77 | labelColor: 'red', 78 | }); 79 | 80 | template.setCertificate(process.env.APPLE_PASS_CERTIFICATE as string); 81 | template.setPrivateKey( 82 | process.env.APPLE_PASS_PRIVATE_KEY as string, 83 | process.env.APPLE_PASS_KEY_PASSWORD, 84 | ); 85 | 86 | await expect( 87 | template.pushUpdates( 88 | '0e40d22a36e101a59ab296d9e6021df3ee1dcf95e29e8ab432213b12ba522dbb', 89 | ), 90 | ).resolves.toEqual( 91 | expect.objectContaining({ 92 | ':status': 200, 93 | 'apns-id': expect.any(String), 94 | }), 95 | ); 96 | }, 40000); 97 | }); 98 | -------------------------------------------------------------------------------- /__tests__/w3cdate.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { 4 | getW3CDateString, 5 | isValidW3CDateString, 6 | getDateFromW3CString, 7 | } from '../src/lib/w3cdate'; 8 | 9 | describe('W3C dates strings ', () => { 10 | it('isValidW3CDateString', () => { 11 | expect(isValidW3CDateString('2012-07-22T14:25-08:00')).toBeTruthy(); 12 | // allow seconds too 13 | expect(isValidW3CDateString('2018-07-16T19:20:30+01:00')).toBeTruthy(); 14 | expect(isValidW3CDateString('2012-07-22')).toBeFalsy(); 15 | }); 16 | 17 | it('getW3CDateString', () => { 18 | const date = new Date(); 19 | const res = getW3CDateString(date); 20 | expect(isValidW3CDateString(res)).toBeTruthy(); 21 | // must not cust seconds if supplied as string 22 | expect(getW3CDateString('2018-07-16T19:20:30+01:00')).toBe( 23 | '2018-07-16T19:20:30+01:00', 24 | ); 25 | }); 26 | 27 | it('parseable to Date', () => { 28 | const date = new Date(); 29 | const str = getW3CDateString(date); 30 | const date1 = new Date(str); 31 | // it's up to minutes, so, check everything apart 32 | expect(isFinite(date1.getDate())).toBeTruthy(); 33 | expect(date1.getFullYear()).toBe(date.getFullYear()); 34 | expect(date1.getMonth()).toBe(date.getMonth()); 35 | expect(date1.getDay()).toBe(date.getDay()); 36 | expect(date1.getHours()).toBe(date.getHours()); 37 | expect(date1.getMinutes()).toBe(date.getMinutes()); 38 | expect(date.getTimezoneOffset()).toBe(date.getTimezoneOffset()); 39 | }); 40 | 41 | it('throws on invalid argument type', () => { 42 | expect(() => getW3CDateString({ byaka: 'buka' })).toThrow(TypeError); 43 | }); 44 | 45 | it.skip('circle conversion', () => { 46 | expect( 47 | getW3CDateString(getDateFromW3CString('2011-12-08T13:00-04:00')), 48 | ).toBe('2011-12-08T13:00-04:00'); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /bin/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "node/shebang": "off", 4 | "no-sync": "off", 5 | "no-process-exit": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /bin/passkit-keys: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // vi:set ft=javascript: 3 | 4 | 'use strict'; 5 | 6 | const fs = require('fs'); 7 | const path = require('path'); 8 | const { execFileSync } = require('child_process'); 9 | 10 | // parsing command-line arguments 11 | function printUsage() { 12 | console.info('USAGE: passkit-keys ./path/to/dirWithPC12keys'); 13 | } 14 | 15 | if (process.argv.length < 3) { 16 | printUsage(); 17 | process.exit(1); 18 | } 19 | 20 | // check if given parameter is a path 21 | const keysDirectory = path.resolve(process.argv[2]); 22 | const stats = fs.statSync(keysDirectory); 23 | if (!stats.isDirectory()) { 24 | console.error(`${process.argv[2]} is not a directory!`); 25 | printUsage(); 26 | process.exit(1); 27 | } 28 | 29 | // Extract the Apple Worldwide Developer Relations Certification Authority from 30 | // Keychain and store it as wwdr.pem in the keys directory. 31 | console.info( 32 | `Extracting Apple WWDR certificate into directory ${keysDirectory}`, 33 | ); 34 | const res = execFileSync( 35 | 'security', 36 | [ 37 | 'find-certificate', 38 | '-p', 39 | '-c', 40 | 'Apple Worldwide Developer Relations Certification Authority', 41 | ], 42 | { stdio: ['inherit', 'pipe', 'inherit'] }, 43 | ); 44 | fs.writeFileSync(path.join(keysDirectory, 'wwdr.pem'), res); 45 | 46 | // Convert all P12 files in the keys directory into PEM files. 47 | // 48 | // When exporting the Passbook certificate from Keychain, we get a P12 files, 49 | // but to sign the certificate we need a PEM file. 50 | console.info( 51 | 'Generating PEM versions for all P12 keys at %s...', 52 | keysDirectory, 53 | ); 54 | fs 55 | .readdirSync(keysDirectory) 56 | .filter(file => path.extname(file) === '.p12') 57 | .map(file => path.resolve(keysDirectory, file)) 58 | .forEach(file => { 59 | const outputFile = file.replace(/p12$/, 'pem'); 60 | if (fs.existsSync(outputFile)) { 61 | console.warn('Skipping %s, PEM already exists', file); 62 | } else { 63 | console.info('Generating PEM from file %s...', file); 64 | execFileSync('openssl', ['pkcs12', '-in', file, '-out', outputFile], { 65 | stdio: ['inherit', 'inherit', 'inherit'], 66 | }); 67 | } 68 | }); 69 | -------------------------------------------------------------------------------- /images/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/background.png -------------------------------------------------------------------------------- /images/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/background@2x.png -------------------------------------------------------------------------------- /images/background@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/background@3x.png -------------------------------------------------------------------------------- /images/footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/footer.png -------------------------------------------------------------------------------- /images/footer@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/footer@2x.png -------------------------------------------------------------------------------- /images/footer@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/footer@3x.png -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/icon.png -------------------------------------------------------------------------------- /images/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/icon@2x.png -------------------------------------------------------------------------------- /images/icon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/icon@3x.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/logo.png -------------------------------------------------------------------------------- /images/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/logo@2x.png -------------------------------------------------------------------------------- /images/logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/logo@3x.png -------------------------------------------------------------------------------- /images/strip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/strip.png -------------------------------------------------------------------------------- /images/strip@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/strip@2x.png -------------------------------------------------------------------------------- /images/strip@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/strip@3x.png -------------------------------------------------------------------------------- /images/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/thumbnail.png -------------------------------------------------------------------------------- /images/thumbnail@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/thumbnail@2x.png -------------------------------------------------------------------------------- /images/thumbnail@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinovyatkin/pass-js/84ddb92210a0243c5947a063d4e1d0654604d5ef/images/thumbnail@3x.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | verbose: true, 4 | coverageReporters: ['text', 'json', 'cobertura', 'lcov'], 5 | moduleFileExtensions: ['ts', 'js'], 6 | preset: 'ts-jest', 7 | testEnvironment: 'node', 8 | setupFilesAfterEnv: ['jest-extended'], 9 | watchPathIgnorePatterns: [ 10 | '/node_modules/', 11 | '/.sonarlint/', 12 | '/dist/', 13 | '/coverage/', 14 | '/.vscode/', 15 | ], 16 | globals: { 17 | 'ts-jest': { 18 | diagnostics: { 19 | ignoreCodes: [ 20 | 2571, 21 | 2532, 22 | 2488, 23 | 2322, 24 | 2339, 25 | 2345, 26 | 6031, 27 | 6133, 28 | 7006, 29 | 18003, 30 | ], 31 | }, 32 | }, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /keys/.gitignore: -------------------------------------------------------------------------------- 1 | com.example.passbook.pem 2 | -------------------------------------------------------------------------------- /keys/wwdr.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEIjCCAwqgAwIBAgIIAd68xDltoBAwDQYJKoZIhvcNAQEFBQAwYjELMAkGA1UE 3 | BhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsTHUFwcGxlIENlcnRp 4 | ZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBSb290IENBMB4XDTEz 5 | MDIwNzIxNDg0N1oXDTIzMDIwNzIxNDg0N1owgZYxCzAJBgNVBAYTAlVTMRMwEQYD 6 | VQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3JsZHdpZGUgRGV2ZWxv 7 | cGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3aWRlIERldmVsb3Bl 8 | ciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3 9 | DQEBAQUAA4IBDwAwggEKAoIBAQDKOFSmy1aqyCQ5SOmM7uxfuH8mkbw0U3rOfGOA 10 | YXdkXqUHI7Y5/lAtFVZYcC1+xG7BSoU+L/DehBqhV8mvexj/avoVEkkVCBmsqtsq 11 | Mu2WY2hSFT2Miuy/axiV4AOsAX2XBWfODoWVN2rtCbauZ81RZJ/GXNG8V25nNYB2 12 | NqSHgW44j9grFU57Jdhav06DwY3Sk9UacbVgnJ0zTlX5ElgMhrgWDcHld0WNUEi6 13 | Ky3klIXh6MSdxmilsKP8Z35wugJZS3dCkTm59c3hTO/AO0iMpuUhXf1qarunFjVg 14 | 0uat80YpyejDi+l5wGphZxWy8P3laLxiX27Pmd3vG2P+kmWrAgMBAAGjgaYwgaMw 15 | HQYDVR0OBBYEFIgnFwmpthhgi+zruvZHWcVSVKO3MA8GA1UdEwEB/wQFMAMBAf8w 16 | HwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wLgYDVR0fBCcwJTAjoCGg 17 | H4YdaHR0cDovL2NybC5hcHBsZS5jb20vcm9vdC5jcmwwDgYDVR0PAQH/BAQDAgGG 18 | MBAGCiqGSIb3Y2QGAgEEAgUAMA0GCSqGSIb3DQEBBQUAA4IBAQBPz+9Zviz1smwv 19 | j+4ThzLoBTWobot9yWkMudkXvHcs1Gfi/ZptOllc34MBvbKuKmFysa/Nw0Uwj6OD 20 | Dc4dR7Txk4qjdJukw5hyhzs+r0ULklS5MruQGFNrCk4QttkdUGwhgAqJTleMa1s8 21 | Pab93vcNIx0LSiaHP7qRkkykGRIZbVf1eliHe2iK5IaMSuviSRSqpd1VAKmuu0sw 22 | ruGgsbwpgOYJd+W+NKIByn/c4grmO7i77LpilfMFY0GCzQ87HUyVpNur+cmV6U/k 23 | TecmmYHpvPm0KdIBembhLoz2IYrF+Hjhga6/05Cdqa3zr/04GpZnMBxRpVzscYqC 24 | tGwPDBUf 25 | -----END CERTIFICATE----- 26 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | '*.ts': [ 5 | 'eslint --fix --quiet -f visualstudio', 6 | 'prettier --write', 7 | 'git add', 8 | 'node -r env-app-yaml/config --max_old_space_size=2048 --expose-gc node_modules/jest/bin/jest --maxWorkers=2 --silent --forceExit --errorOnDeprecated --ci --bail --findRelatedTests', 9 | ], 10 | '.app.yml': ['git rm'], 11 | '*.{yaml,yml}': ['prettier --write', 'git add'], 12 | '*.{md,json}': ['prettier --write', 'git add'], 13 | 14 | '.codecov.yml': () => 15 | 'curl -f --silent --data-binary @.codecov.yml https://codecov.io/validate', 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@walletpass/pass-js", 3 | "description": "Apple Wallet Pass generating and pushing updates from Node.js", 4 | "author": "Konstantin Vyatkin ", 5 | "version": "6.9.7", 6 | "homepage": "https://github.com/walletpass/pass-js", 7 | "license": "MIT", 8 | "licenses": [ 9 | { 10 | "type": "MIT", 11 | "url": "https://github.com/walletpass/pass-js/blob/master/MIT-LICENSE" 12 | } 13 | ], 14 | "funding": { 15 | "type": "opencollective", 16 | "url": "https://opencollective.com/walletpass" 17 | }, 18 | "engines": { 19 | "node": ">=10.10" 20 | }, 21 | "bin": { 22 | "passkit-keys": "./bin/passkit-keys" 23 | }, 24 | "files": [ 25 | "dist/*.js", 26 | "dist/lib/*.js", 27 | "dist/*.ts", 28 | "dist/lib/*.ts", 29 | "bin/passkit-keys" 30 | ], 31 | "types": "./dist/index.d.ts", 32 | "main": "./dist/index.js", 33 | "devDependencies": { 34 | "@destinationstransfers/eslint-plugin": "2.9.190", 35 | "@types/buffer-crc32": "0.2.0", 36 | "@types/color-name": "1.1.1", 37 | "@types/jest": "27.4.1", 38 | "@types/node": "12.20.29", 39 | "@types/node-forge": "1.0.1", 40 | "@types/yauzl": "2.9.2", 41 | "@typescript-eslint/eslint-plugin": "5.62.0", 42 | "@typescript-eslint/parser": "5.62.0", 43 | "@typescript-eslint/typescript-estree": "5.62.0", 44 | "env-app-yaml": "1.0.0", 45 | "eslint": "8.46.0", 46 | "husky": "7.0.4", 47 | "jest": "27.5.1", 48 | "jest-extended": "2.0.0", 49 | "jest-junit": "13.0.0", 50 | "lint-staged": "12.5.0", 51 | "ts-jest": "27.1.4", 52 | "typescript": "4.4.4" 53 | }, 54 | "scripts": { 55 | "test": "node -r env-app-yaml/config --expose-gc node_modules/jest/bin/jest --detectOpenHandles --logHeapUsage --maxWorkers=1", 56 | "lint": "eslint \"{src,__tests__}/**/*.ts\" --ignore-pattern \"*test*\" --ignore-path .gitignore", 57 | "postversion": "git push origin master --follow-tags", 58 | "prepublishOnly": "tsc", 59 | "costs": "npx cost-of-modules --no-install" 60 | }, 61 | "keywords": [ 62 | "apple", 63 | "wallet", 64 | "pass", 65 | "passkit", 66 | "iOS", 67 | "generating", 68 | "APN" 69 | ], 70 | "repository": { 71 | "type": "git", 72 | "url": "https://github.com/walletpass/pass-js.git" 73 | }, 74 | "bugs": { 75 | "url": "https://github.com/walletpass/pass-js/issues" 76 | }, 77 | "directories": { 78 | "lib": "src", 79 | "test": "__tests__" 80 | }, 81 | "dependencies": { 82 | "buffer-crc32": "0.2.13", 83 | "color-name": "1.1.4", 84 | "do-not-zip": "1.0.0", 85 | "event-iterator": "2.0.0", 86 | "imagesize": "1.0.0", 87 | "node-forge": "1.3.1", 88 | "strip-json-comments": "4.0.0", 89 | "yauzl": "2.10.0" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled": true, 3 | "extends": [ 4 | ":pinSkipCi", 5 | "default:pinDigestsDisabled", 6 | "config:base", 7 | ":automergeMinor" 8 | ], 9 | "baseBranch": "master", 10 | "respectLatest": false, 11 | "labels": ["dependencies"], 12 | "timezone": "America/Santo_Domingo", 13 | "pin": { 14 | "requiredStatusChecks": null, 15 | "automerge": true 16 | }, 17 | "vulnerabilityAlerts": { 18 | "labels": ["dependencies", "security"] 19 | }, 20 | "packageRules": [ 21 | { 22 | "depTypeList": ["dependencies"], 23 | "bumpVersion": "patch" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/@types/do-not-zip/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'do-not-zip'; 2 | -------------------------------------------------------------------------------- /src/@types/imagesize/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'imagesize'; 2 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Common constants for fields names and values: 3 | * https://developer.apple.com/library/content/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/LowerLevel.html#//apple_ref/doc/uid/TP40012026-CH3-SW3 4 | * 5 | */ 6 | 7 | 'use strict'; 8 | 9 | import { 10 | PassStyle, 11 | TransitType, 12 | TextAlignment, 13 | NumberStyle, 14 | BarcodeFormat, 15 | DataStyleFormat, 16 | DataDetectors, 17 | PassCommonStructure, 18 | ApplePass, 19 | } from './interfaces'; 20 | import { ImageType, ImageDensity } from './lib/images'; 21 | 22 | export const PASS_MIME_TYPE = 'application/vnd.apple.pkpass'; 23 | 24 | export const TRANSIT = { 25 | AIR: 'PKTransitTypeAir' as TransitType, 26 | BOAT: 'PKTransitTypeBoat' as TransitType, 27 | BUS: 'PKTransitTypeBus' as TransitType, 28 | TRAIN: 'PKTransitTypeTrain' as TransitType, 29 | GENERIC: 'PKTransitTypeGeneric' as TransitType, 30 | }; 31 | 32 | export const textDirection = { 33 | LEFT: 'PKTextAlignmentLeft' as TextAlignment, 34 | CENTER: 'PKTextAlignmentCenter' as TextAlignment, 35 | RIGHT: 'PKTextAlignmentRight' as TextAlignment, 36 | NATURAL: 'PKTextAlignmentNatural' as TextAlignment, 37 | }; 38 | 39 | export const barcodeFormat = { 40 | QR: 'PKBarcodeFormatQR' as BarcodeFormat, 41 | PDF417: 'PKBarcodeFormatPDF417' as BarcodeFormat, 42 | Aztec: 'PKBarcodeFormatAztec' as BarcodeFormat, 43 | Code128: 'PKBarcodeFormatCode128' as BarcodeFormat, 44 | }; 45 | 46 | export const dateTimeFormat = { 47 | NONE: 'PKDateStyleNone' as DataStyleFormat, 48 | SHORT: 'PKDateStyleShort' as DataStyleFormat, 49 | MEDIUM: 'PKDateStyleMedium' as DataStyleFormat, 50 | LONG: 'PKDateStyleLong' as DataStyleFormat, 51 | FULL: 'PKDateStyleFull' as DataStyleFormat, 52 | }; 53 | 54 | export const dataDetector = { 55 | PHONE: 'PKDataDetectorTypePhoneNumber' as DataDetectors, 56 | LINK: 'PKDataDetectorTypeLink' as DataDetectors, 57 | ADDRESS: 'PKDataDetectorTypeAddress' as DataDetectors, 58 | CALENDAR: 'PKDataDetectorTypeCalendarEvent' as DataDetectors, 59 | }; 60 | 61 | export const numberStyle = { 62 | DECIMAL: 'PKNumberStyleDecimal' as NumberStyle, 63 | PERCENT: 'PKNumberStylePercent' as NumberStyle, 64 | SCIENTIFIC: 'PKNumberStyleScientific' as NumberStyle, 65 | SPELL_OUT: 'PKNumberStyleSpellOut' as NumberStyle, 66 | }; 67 | 68 | /** 69 | * Supported images. 70 | */ 71 | 72 | export const IMAGES: { 73 | [k in ImageType]: { width: number; height: number; required?: boolean }; 74 | } = { 75 | icon: { 76 | width: 29, 77 | height: 29, 78 | required: true, 79 | }, 80 | logo: { 81 | width: 160, 82 | height: 50, 83 | required: true, 84 | }, 85 | background: { 86 | width: 180, 87 | height: 220, 88 | }, 89 | footer: { 90 | width: 295, 91 | height: 15, 92 | }, 93 | strip: { 94 | width: 375, 95 | height: 123, 96 | }, 97 | thumbnail: { 98 | width: 90, 99 | height: 90, 100 | }, 101 | }; 102 | 103 | export const DENSITIES: ReadonlySet = new Set(['1x', '2x', '3x']); 104 | 105 | // Supported passbook styles. 106 | export const PASS_STYLES: ReadonlySet = new Set([ 107 | 'boardingPass', 108 | 'coupon', 109 | 'eventTicket', 110 | 'storeCard', 111 | 'generic', 112 | ]); 113 | 114 | // Optional top level fields 115 | // Top-level pass fields. 116 | // https://developer.apple.com/library/content/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/TopLevel.html#//apple_ref/doc/uid/TP40012026-CH2-SW1 117 | export const TOP_LEVEL_FIELDS: { 118 | [k in keyof ApplePass]?: { 119 | required?: boolean; 120 | type: 'string' | 'number' | typeof Array | typeof Boolean | typeof Object; 121 | templatable?: boolean; 122 | localizable?: boolean; 123 | minlength?: number; 124 | }; 125 | } = { 126 | // Standard Keys 127 | description: { 128 | required: true, 129 | type: 'string', 130 | templatable: true, 131 | localizable: true, 132 | }, 133 | organizationName: { 134 | required: true, 135 | type: 'string', 136 | templatable: true, 137 | localizable: true, 138 | }, 139 | passTypeIdentifier: { 140 | required: true, 141 | type: 'string', 142 | templatable: true, 143 | }, 144 | serialNumber: { 145 | required: true, 146 | type: 'string', 147 | }, 148 | teamIdentifier: { 149 | required: true, 150 | type: 'string', 151 | templatable: true, 152 | }, 153 | sharingProhibited: { 154 | required: false, 155 | type: Boolean, 156 | templatable: true, 157 | }, 158 | associatedStoreIdentifiers: { 159 | required: false, 160 | type: Array, 161 | templatable: true, 162 | }, 163 | // Expiration Keys 164 | expirationDate: { 165 | type: 'string', // W3C date, as a string 166 | }, 167 | voided: { 168 | type: Boolean, 169 | }, 170 | // Relevance Keys 171 | beacons: { 172 | type: Array, 173 | }, 174 | locations: { 175 | type: Array, 176 | }, 177 | maxDistance: { 178 | type: 'number', 179 | }, 180 | relevantDate: { 181 | type: 'string', // W3C date, as a string 182 | }, 183 | // Visual Appearance Keys 184 | barcodes: { 185 | type: Array, 186 | }, 187 | backgroundColor: { 188 | type: 'string', 189 | templatable: true, 190 | }, 191 | foregroundColor: { 192 | type: 'string', 193 | templatable: true, 194 | }, 195 | groupingIdentifier: { 196 | type: 'string', 197 | }, 198 | labelColor: { 199 | type: 'string', 200 | templatable: true, 201 | }, 202 | logoText: { 203 | type: 'string', 204 | templatable: true, 205 | localizable: true, 206 | }, 207 | suppressStripShine: { 208 | type: Boolean, 209 | templatable: true, 210 | }, 211 | // Web Service Keys 212 | authenticationToken: { 213 | type: 'string', 214 | minlength: 16, 215 | }, 216 | webServiceURL: { 217 | type: 'string', 218 | templatable: true, 219 | }, 220 | }; 221 | 222 | // Pass structure keys. 223 | // https://developer.apple.com/library/content/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/LowerLevel.html#//apple_ref/doc/uid/TP40012026-CH3-SW3 224 | export const STRUCTURE_FIELDS: readonly (keyof PassCommonStructure)[] = [ 225 | 'auxiliaryFields', 226 | 'backFields', 227 | 'headerFields', 228 | 'primaryFields', 229 | 'secondaryFields', 230 | ]; 231 | 232 | export const BARCODES_FORMAT: ReadonlySet = new Set([ 233 | 'PKBarcodeFormatQR', 234 | 'PKBarcodeFormatPDF417', 235 | 'PKBarcodeFormatAztec', 236 | 'PKBarcodeFormatCode128', 237 | ]); 238 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as constants from './constants'; 2 | 3 | export { constants }; 4 | export { Template } from './template'; 5 | export { Pass } from './pass'; 6 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Field accessors class 3 | * 4 | * @see {@link https://developer.apple.com/library/archive/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/FieldDictionary.html} 5 | */ 6 | 7 | import { PassColor } from './lib/pass-color'; 8 | import { FieldsMap } from './lib/fieldsMap'; 9 | 10 | export type DataDetectors = 11 | | 'PKDataDetectorTypePhoneNumber' 12 | | 'PKDataDetectorTypeLink' 13 | | 'PKDataDetectorTypeAddress' 14 | | 'PKDataDetectorTypeCalendarEvent'; 15 | 16 | export type DataStyleFormat = 17 | | 'PKDateStyleNone' 18 | | 'PKDateStyleShort' 19 | | 'PKDateStyleMedium' 20 | | 'PKDateStyleLong' 21 | | 'PKDateStyleFull'; 22 | 23 | export type TextAlignment = 24 | | 'PKTextAlignmentLeft' 25 | | 'PKTextAlignmentCenter' 26 | | 'PKTextAlignmentRight' 27 | | 'PKTextAlignmentNatural'; 28 | 29 | export type NumberStyle = 30 | | 'PKNumberStyleDecimal' 31 | | 'PKNumberStylePercent' 32 | | 'PKNumberStyleScientific' 33 | | 'PKNumberStyleSpellOut'; 34 | 35 | export type FieldDescriptor = { 36 | // Standard Field Dictionary Keys 37 | label?: string; 38 | attributedValue?: string | number; 39 | changeMessage?: string; 40 | dataDetectorTypes?: DataDetectors[]; 41 | } & ( 42 | | { 43 | value: string; 44 | textAlignment?: TextAlignment; 45 | } 46 | | { 47 | value: Date; 48 | // Date Style Keys 49 | dateStyle?: DataStyleFormat; 50 | ignoresTimeZone?: boolean; 51 | isRelative?: boolean; 52 | timeStyle?: DataStyleFormat; 53 | } 54 | | { 55 | value: number; 56 | // Number Style Keys 57 | currencyCode?: string; 58 | numberStyle?: NumberStyle; 59 | }); 60 | 61 | export type Field = { 62 | // Standard Field Dictionary Keys 63 | key: string; 64 | } & FieldDescriptor; 65 | 66 | export type PassStyle = 67 | | 'boardingPass' 68 | | 'coupon' 69 | | 'eventTicket' 70 | | 'storeCard' 71 | | 'generic'; 72 | 73 | export type BarcodeFormat = 74 | | 'PKBarcodeFormatQR' 75 | | 'PKBarcodeFormatPDF417' 76 | | 'PKBarcodeFormatAztec' 77 | | 'PKBarcodeFormatCode128'; 78 | export interface BarcodeDescriptor { 79 | /** 80 | * Barcode format. For the barcode dictionary, you can use only the following values: PKBarcodeFormatQR, PKBarcodeFormatPDF417, or PKBarcodeFormatAztec. For dictionaries in the barcodes array, you may also use PKBarcodeFormatCode128. 81 | */ 82 | format: BarcodeFormat; 83 | /** 84 | * Message or payload to be displayed as a barcode. 85 | */ 86 | message: string; 87 | /** 88 | * Text encoding that is used to convert the message from the string representation to a data representation to render the barcode. The value is typically iso-8859-1, but you may use another encoding that is supported by your barcode scanning infrastructure. 89 | */ 90 | messageEncoding: string; 91 | /** 92 | * Optional. Text displayed near the barcode. For example, a human-readable version of the barcode data in case the barcode doesn’t scan. 93 | */ 94 | altText?: string; 95 | } 96 | 97 | /** 98 | * Top-Level Keys 99 | * The top level of the pass.json file is a dictionary. 100 | * The following sections list the required and optional keys used in this dictionary. 101 | * For each key whose value is a dictionary or an array of dictionaries, 102 | * there is also a section in Lower-Level Keys that lists the keys for that dictionary. 103 | * 104 | * @see {@link https://developer.apple.com/library/archive/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/TopLevel.html#//apple_ref/doc/uid/TP40012026-CH2-SW1} 105 | */ 106 | 107 | /** 108 | * Information that is required for all passes. 109 | */ 110 | export interface PassStandardKeys { 111 | /** 112 | * Brief description of the pass, used by the iOS accessibility technologies. 113 | * Don’t try to include all of the data on the pass in its description, 114 | * just include enough detail to distinguish passes of the same type. 115 | */ 116 | description: string; 117 | /** 118 | * Version of the file format. The value must be 1. 119 | */ 120 | formatVersion: 1; 121 | /** 122 | * Display name of the organization that originated and signed the pass. 123 | */ 124 | organizationName: string; 125 | /** 126 | * Pass type identifier, as issued by Apple. 127 | * The value must correspond with your signing certificate. 128 | */ 129 | passTypeIdentifier: string; 130 | /** 131 | * Serial number that uniquely identifies the pass. 132 | * No two passes with the same pass type identifier may have the same serial number. 133 | */ 134 | serialNumber: string; 135 | /** 136 | * Team identifier of the organization that originated and signed the pass, as issued by Apple. 137 | */ 138 | teamIdentifier: string; 139 | /** 140 | * Possibility to prohibit a sharing of pass 141 | */ 142 | sharingProhibited: boolean; 143 | } 144 | 145 | /** 146 | * Information about an app that is associated with a pass. 147 | */ 148 | export interface PassAssociatedAppKeys { 149 | /** 150 | * A URL to be passed to the associated app when launching it. 151 | * The app receives this URL in the application:didFinishLaunchingWithOptions: 152 | * and application:openURL:options: methods of its app delegate. 153 | * If this key is present, the associatedStoreIdentifiers key must also be present. 154 | */ 155 | appLaunchURL?: string; 156 | /** 157 | * A list of iTunes Store item identifiers for the associated apps. 158 | * Only one item in the list is used—the first item identifier for an app 159 | * compatible with the current device. 160 | * If the app is not installed, the link opens the App Store and shows the app. 161 | * If the app is already installed, the link launches the app. 162 | */ 163 | associatedStoreIdentifiers?: number[]; 164 | } 165 | 166 | /** 167 | * Custom information about a pass provided for a companion app to use. 168 | */ 169 | export interface PassCompanionAppKeys { 170 | /** 171 | * Custom information for companion apps. This data is not displayed to the user. 172 | * For example, a pass for a cafe could include information about 173 | * the user’s favorite drink and sandwich in a machine-readable form 174 | * for the companion app to read, making it easy to place an order for “the usual” from the app. 175 | */ 176 | userInfo?: any; // eslint-disable-line @typescript-eslint/no-explicit-any 177 | } 178 | 179 | /** 180 | * Information about when a pass expires and whether it is still valid. 181 | * A pass is marked as expired if the current date is after the pass’s expiration date, 182 | * or if the pass has been explicitly marked as voided. 183 | */ 184 | export interface PassExpirationKeys { 185 | /** 186 | * Date and time when the pass expires. 187 | * The value must be a complete date with hours and minutes, 188 | * and may optionally include seconds. 189 | */ 190 | expirationDate?: string | Date; 191 | /** 192 | * Indicates that the pass is void—for example, a one time use coupon that has been redeemed. 193 | * The default value is false. 194 | */ 195 | voided?: boolean; 196 | } 197 | 198 | /** 199 | * Information about a location beacon. 200 | */ 201 | export interface Beacon { 202 | /** 203 | * Unique identifier of a Bluetooth Low Energy location beacon. 204 | */ 205 | proximityUUID: string; 206 | /** 207 | * Major identifier of a Bluetooth Low Energy location beacon. 208 | */ 209 | major?: number; 210 | /** 211 | * Minor identifier of a Bluetooth Low Energy location beacon. 212 | */ 213 | minor?: number; 214 | /** 215 | * Text displayed on the lock screen when the pass is currently relevant. 216 | * For example, a description of the nearby location 217 | * 218 | * @example “Store nearby on 1st and Main.” 219 | */ 220 | relevantText?: string; 221 | } 222 | 223 | /** 224 | * Location Dictionary Keys 225 | */ 226 | export interface Location { 227 | /** 228 | * Latitude, in degrees, of the location. 229 | */ 230 | latitude: number; 231 | /** 232 | * Longitude, in degrees, of the location. 233 | */ 234 | longitude: number; 235 | /** 236 | * Altitude, in meters, of the location. 237 | */ 238 | altitude?: number; 239 | /** 240 | * Text displayed on the lock screen when the pass is currently relevant. 241 | * For example, a description of the nearby location 242 | * 243 | * @example “Store nearby on 1st and Main.” 244 | */ 245 | relevantText?: string; 246 | } 247 | 248 | /** 249 | * Information about where and when a pass is relevant. 250 | */ 251 | export interface PassRelevanceKeys { 252 | /** 253 | * Beacons marking locations where the pass is relevant. 254 | */ 255 | beacons?: Beacon[]; 256 | /** 257 | * Locations where the pass is relevant. 258 | * For example, the location of your store. 259 | */ 260 | locations?: Location[]; 261 | /** 262 | * Maximum distance in meters from a relevant latitude and longitude that the pass is relevant. 263 | * This number is compared to the pass’s default distance and the smaller value is used. 264 | */ 265 | maxDistance?: number; 266 | /** 267 | * Date and time when the pass becomes relevant. 268 | * For example, the start time of a movie. 269 | * The value must be a complete date with hours and minutes, 270 | * and may optionally include seconds. 271 | */ 272 | relevantDate?: string | Date; 273 | } 274 | 275 | /** 276 | * Pass common structure keys 277 | * 278 | * @see {@link https://developer.apple.com/library/content/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/LowerLevel.html#//apple_ref/doc/uid/TP40012026-CH3-SW3} 279 | */ 280 | export interface PassCommonStructure { 281 | /** 282 | * Fields to be displayed in the header on the front of the pass. 283 | * Use header fields sparingly; unlike all other fields, 284 | * they remain visible when a stack of passes are displayed. 285 | */ 286 | headerFields?: Field[] | FieldsMap; 287 | /** 288 | * Fields to be displayed prominently on the front of the pass. 289 | */ 290 | primaryFields?: Field[] | FieldsMap; 291 | /** 292 | * Fields to be displayed on the front of the pass. 293 | */ 294 | secondaryFields?: Field[] | FieldsMap; 295 | /** 296 | * Additional fields to be displayed on the front of the pass. 297 | */ 298 | auxiliaryFields?: Field[] | FieldsMap; 299 | /** 300 | * Fields to be on the back of the pass. 301 | */ 302 | backFields?: Field[] | FieldsMap; 303 | } 304 | 305 | /** 306 | * Keys that define the visual style and appearance of the pass. 307 | */ 308 | export interface PassVisualAppearanceKeys { 309 | /** 310 | * Information specific to the pass’s barcode. 311 | * For this dictionary’s keys, see Barcode Dictionary Keys. 312 | * DEPRECATED in iOS 9.0 and later; use `barcodes` instead. 313 | */ 314 | barcode?: BarcodeDescriptor; 315 | /** 316 | * Information specific to the pass’s barcode. 317 | * The system uses the first valid barcode dictionary in the array. 318 | * Additional dictionaries can be added as fallbacks. 319 | */ 320 | barcodes?: BarcodeDescriptor[]; 321 | /** 322 | * Background color of the pass, specified as an CSS-style RGB triple. 323 | * 324 | * @example rgb(23, 187, 82) 325 | */ 326 | backgroundColor?: PassColor | string; 327 | /** 328 | * Foreground color of the pass, specified as a CSS-style RGB triple. 329 | * 330 | * @example rgb(100, 10, 110) 331 | */ 332 | foregroundColor?: PassColor | string; 333 | /** 334 | * Optional for event tickets and boarding passes; otherwise not allowed. 335 | * Identifier used to group related passes. 336 | * If a grouping identifier is specified, passes with the same style, 337 | * pass type identifier, and grouping identifier are displayed as a group. 338 | * Otherwise, passes are grouped automatically. 339 | * Use this to group passes that are tightly related, 340 | * such as the boarding passes for different connections of the same trip. 341 | */ 342 | groupingIdentifier?: string; 343 | /** 344 | * Color of the label text, specified as a CSS-style RGB triple. 345 | * 346 | * @example rgb(255, 255, 255) 347 | */ 348 | labelColor?: PassColor | string; 349 | /** 350 | * Color of the strip text, specified as a CSS-style RGB triple. 351 | * 352 | * @example rgb(255, 255, 255) 353 | */ 354 | stripColor?: PassColor | string; 355 | /** 356 | * Text displayed next to the logo on the pass. 357 | */ 358 | logoText?: string; 359 | /** 360 | * If true, the strip image is displayed without a shine effect. 361 | * The default value prior to iOS 7.0 is false. 362 | * In iOS 7.0, a shine effect is never applied, and this key is deprecated. 363 | */ 364 | suppressStripShine?: boolean; 365 | } 366 | 367 | export interface PassWebServiceKeys { 368 | /** 369 | * The URL of a web service that conforms to the API described in PassKit Web Service Reference. 370 | * The web service must use the HTTPS protocol in production; the leading https:// is included in the value of this key. 371 | * On devices configured for development, there is UI in Settings to allow HTTP web services. You can use the options 372 | * parameter to set allowHTTP to be able to use URLs that use the HTTP protocol. 373 | * 374 | * @see {@link https://developer.apple.com/library/archive/documentation/PassKit/Reference/PassKit_WebService/WebService.html#//apple_ref/doc/uid/TP40011988} 375 | */ 376 | webServiceURL?: URL | string; 377 | /** 378 | * The authentication token to use with the web service. 379 | * The token must be 16 characters or longer. 380 | */ 381 | authenticationToken?: string; 382 | } 383 | 384 | export interface NFCDictionary { 385 | /** 386 | * The payload to be transmitted to the Apple Pay terminal. 387 | * Must be 64 bytes or less. 388 | * Messages longer than 64 bytes are truncated by the system. 389 | */ 390 | message: string; 391 | /** 392 | * The public encryption key used by the Value Added Services protocol. 393 | * Use a Base64 encoded X.509 SubjectPublicKeyInfo structure containing a ECDH public key for group P256. 394 | */ 395 | encryptionPublicKey?: string | import('node-forge').pki.PublicKey; 396 | } 397 | 398 | /** 399 | * NFC-enabled pass keys support sending reward card information as part of an Apple Pay transaction. 400 | */ 401 | 402 | export type TransitType = 403 | | 'PKTransitTypeAir' 404 | | 'PKTransitTypeBoat' 405 | | 'PKTransitTypeBus' 406 | | 'PKTransitTypeTrain' 407 | | 'PKTransitTypeGeneric'; 408 | 409 | export interface BoardingPass { 410 | boardingPass: { 411 | /** 412 | * Type of transit. 413 | */ 414 | transitType: TransitType; 415 | } & PassCommonStructure; 416 | } 417 | 418 | export interface CouponPass { 419 | coupon: PassCommonStructure; 420 | } 421 | 422 | export interface EventTicketPass { 423 | eventTicket: PassCommonStructure; 424 | } 425 | 426 | export interface GenericPass { 427 | generic: PassCommonStructure; 428 | } 429 | 430 | export interface StoreCardPass { 431 | storeCard: PassCommonStructure; 432 | nfc?: NFCDictionary; 433 | } 434 | 435 | export type PassStructureFields = 436 | | BoardingPass 437 | | CouponPass 438 | | EventTicketPass 439 | | GenericPass 440 | | StoreCardPass; 441 | 442 | export type ApplePass = PassStandardKeys & 443 | PassAssociatedAppKeys & 444 | PassCompanionAppKeys & 445 | PassExpirationKeys & 446 | PassRelevanceKeys & 447 | PassVisualAppearanceKeys & 448 | PassWebServiceKeys & 449 | PassStructureFields; 450 | 451 | export interface Options { 452 | allowHttp: boolean 453 | } -------------------------------------------------------------------------------- /src/lib/base-pass.ts: -------------------------------------------------------------------------------- 1 | import { ApplePass, Options } from '../interfaces'; 2 | import { BARCODES_FORMAT, STRUCTURE_FIELDS } from '../constants'; 3 | 4 | import { PassColor } from './pass-color'; 5 | import { PassImages } from './images'; 6 | import { Localizations } from './localizations'; 7 | import { getGeoPoint } from './get-geo-point'; 8 | import { PassStructure } from './pass-structure'; 9 | import { getW3CDateString, isValidW3CDateString } from './w3cdate'; 10 | 11 | const STRUCTURE_FIELDS_SET = new Set([...STRUCTURE_FIELDS, 'nfc']); 12 | 13 | export class PassBase extends PassStructure { 14 | readonly images: PassImages; 15 | readonly localization: Localizations; 16 | readonly options: Options | undefined; 17 | 18 | constructor( 19 | fields: Partial = {}, 20 | images?: PassImages, 21 | localizations?: Localizations, 22 | options?: Options, 23 | ) { 24 | super(fields); 25 | 26 | this.options = options; 27 | 28 | // restore via setters 29 | for (const [key, value] of Object.entries(fields)) { 30 | if (!STRUCTURE_FIELDS_SET.has(key) && key in this) { 31 | this[key] = value; 32 | } 33 | } 34 | 35 | // copy images 36 | this.images = new PassImages(images); 37 | 38 | // copy localizations 39 | this.localization = new Localizations(localizations); 40 | } 41 | 42 | // Returns the pass.json object (not a string). 43 | toJSON(): Partial { 44 | const res: Partial = { formatVersion: 1 }; 45 | for (const [field, value] of Object.entries(this.fields)) { 46 | res[field] = value instanceof Date ? getW3CDateString(value) : value; 47 | } 48 | return res; 49 | } 50 | 51 | get passTypeIdentifier(): string | undefined { 52 | return this.fields.passTypeIdentifier; 53 | } 54 | 55 | set passTypeIdentifier(v: string | undefined) { 56 | if (!v) delete this.fields.passTypeIdentifier; 57 | else this.fields.passTypeIdentifier = v; 58 | } 59 | 60 | get teamIdentifier(): string | undefined { 61 | return this.fields.teamIdentifier; 62 | } 63 | 64 | set teamIdentifier(v: string | undefined) { 65 | if (!v) delete this.fields.teamIdentifier; 66 | else this.fields.teamIdentifier = v; 67 | } 68 | 69 | get serialNumber(): string | undefined { 70 | return this.fields.serialNumber; 71 | } 72 | 73 | set serialNumber(v: string | undefined) { 74 | if (!v) delete this.fields.serialNumber; 75 | else this.fields.serialNumber = v; 76 | } 77 | 78 | /** 79 | * Indicates that the sharing of pass can be prohibited. 80 | * 81 | * @type {boolean} 82 | */ 83 | get sharingProhibited(): boolean | undefined { 84 | return this.fields.sharingProhibited; 85 | } 86 | 87 | set sharingProhibited(v) { 88 | if (!v) delete this.fields.sharingProhibited; 89 | else this.fields.sharingProhibited = true; 90 | } 91 | 92 | /** 93 | * Indicates that the pass is void—for example, a one time use coupon that has been redeemed. 94 | * 95 | * @type {boolean} 96 | */ 97 | get voided(): boolean { 98 | return !!this.fields.voided; 99 | } 100 | 101 | set voided(v: boolean) { 102 | if (v) this.fields.voided = true; 103 | else delete this.fields.voided; 104 | } 105 | 106 | /** 107 | * Date and time when the pass expires. 108 | * 109 | */ 110 | get expirationDate(): ApplePass['expirationDate'] { 111 | if (typeof this.fields.expirationDate === 'string') 112 | return new Date(this.fields.expirationDate); 113 | return this.fields.expirationDate; 114 | } 115 | set expirationDate(v: ApplePass['expirationDate']) { 116 | if (!v) delete this.fields.expirationDate; 117 | else { 118 | if (v instanceof Date) { 119 | if (!Number.isFinite(v.getTime())) 120 | throw new TypeError( 121 | `Value for expirationDate must be a valid Date, received ${v}`, 122 | ); 123 | this.fields.expirationDate = v; 124 | } else if (typeof v === 'string') { 125 | if (isValidW3CDateString(v)) this.fields.expirationDate = v; 126 | else { 127 | const date = new Date(v); 128 | if (!Number.isFinite(date.getTime())) 129 | throw new TypeError( 130 | `Value for expirationDate must be a valid Date, received ${v}`, 131 | ); 132 | this.fields.expirationDate = date; 133 | } 134 | } 135 | } 136 | } 137 | 138 | /** 139 | * Date and time when the pass becomes relevant. For example, the start time of a movie. 140 | * Recommended for event tickets and boarding passes; otherwise optional. 141 | * 142 | * @type {string | Date} 143 | */ 144 | get relevantDate(): ApplePass['relevantDate'] { 145 | if (typeof this.fields.relevantDate === 'string') 146 | return new Date(this.fields.relevantDate); 147 | return this.fields.relevantDate; 148 | } 149 | 150 | set relevantDate(v: ApplePass['relevantDate']) { 151 | if (!v) delete this.fields.relevantDate; 152 | else { 153 | if (v instanceof Date) { 154 | if (!Number.isFinite(v.getTime())) 155 | throw new TypeError( 156 | `Value for relevantDate must be a valid Date, received ${v}`, 157 | ); 158 | this.fields.relevantDate = v; 159 | } else if (typeof v === 'string') { 160 | if (isValidW3CDateString(v)) this.fields.relevantDate = v; 161 | else { 162 | const date = new Date(v); 163 | if (!Number.isFinite(date.getTime())) 164 | throw new TypeError( 165 | `Value for relevantDate must be a valid Date, received ${v}`, 166 | ); 167 | this.fields.relevantDate = date; 168 | } 169 | } 170 | } 171 | } 172 | 173 | /** 174 | * A list of iTunes Store item identifiers for the associated apps. 175 | * Only one item in the list is used—the first item identifier for an app 176 | * compatible with the current device. 177 | * If the app is not installed, the link opens the App Store and shows the app. 178 | * If the app is already installed, the link launches the app. 179 | */ 180 | get associatedStoreIdentifiers(): ApplePass['associatedStoreIdentifiers'] { 181 | return this.fields.associatedStoreIdentifiers; 182 | } 183 | set associatedStoreIdentifiers(v: ApplePass['associatedStoreIdentifiers']) { 184 | if (!v) { 185 | delete this.fields.associatedStoreIdentifiers; 186 | return; 187 | } 188 | const arrayOfNumbers = v.filter(n => Number.isInteger(n)); 189 | if (arrayOfNumbers.length > 0) 190 | this.fields.associatedStoreIdentifiers = arrayOfNumbers; 191 | else delete this.fields.associatedStoreIdentifiers; 192 | } 193 | 194 | /** 195 | * Brief description of the pass, used by the iOS accessibility technologies. 196 | * Don’t try to include all of the data on the pass in its description, 197 | * just include enough detail to distinguish passes of the same type. 198 | */ 199 | get description(): string | undefined { 200 | return this.fields.description; 201 | } 202 | 203 | set description(v: string | undefined) { 204 | if (!v) delete this.fields.description; 205 | else this.fields.description = v; 206 | } 207 | 208 | /** 209 | * Display name of the organization that originated and signed the pass. 210 | */ 211 | get organizationName(): string | undefined { 212 | return this.fields.organizationName; 213 | } 214 | set organizationName(v: string | undefined) { 215 | if (!v) delete this.fields.organizationName; 216 | else this.fields.organizationName = v; 217 | } 218 | 219 | /** 220 | * Optional for event tickets and boarding passes; otherwise not allowed. 221 | * Identifier used to group related passes. 222 | * If a grouping identifier is specified, passes with the same style, 223 | * pass type identifier, and grouping identifier are displayed as a group. 224 | * Otherwise, passes are grouped automatically. 225 | * Use this to group passes that are tightly related, 226 | * such as the boarding passes for different connections of the same trip. 227 | */ 228 | get groupingIdentifier(): string | undefined { 229 | return this.fields.groupingIdentifier; 230 | } 231 | 232 | set groupingIdentifier(v: string | undefined) { 233 | if (!v) delete this.fields.groupingIdentifier; 234 | else this.fields.groupingIdentifier = v; 235 | } 236 | 237 | /** 238 | * If true, the strip image is displayed without a shine effect. 239 | * The default value prior to iOS 7.0 is false. 240 | * In iOS 7.0, a shine effect is never applied, and this key is deprecated. 241 | */ 242 | get suppressStripShine(): boolean { 243 | return !!this.fields.suppressStripShine; 244 | } 245 | set suppressStripShine(v: boolean) { 246 | if (!v) delete this.fields.suppressStripShine; 247 | else this.fields.suppressStripShine = true; 248 | } 249 | 250 | /** 251 | * Text displayed next to the logo on the pass. 252 | */ 253 | get logoText(): string | undefined { 254 | return this.fields.logoText; 255 | } 256 | set logoText(v: string | undefined) { 257 | if (!v) delete this.fields.logoText; 258 | else this.fields.logoText = v; 259 | } 260 | 261 | /** 262 | * The URL of a web service that conforms to the API described in PassKit Web Service Reference. 263 | * The web service must use the HTTPS protocol in production; the leading https:// is included in the value of this key. 264 | * On devices configured for development, there is UI in Settings to allow HTTP web services. You can use the options 265 | * parameter to set allowHTTP to be able to use URLs that use the HTTP protocol. 266 | * 267 | * @see {@link https://developer.apple.com/library/archive/documentation/PassKit/Reference/PassKit_WebService/WebService.html#//apple_ref/doc/uid/TP40011988} 268 | */ 269 | get webServiceURL(): URL | string | undefined { 270 | return this.fields.webServiceURL; 271 | } 272 | set webServiceURL(v: URL | string | undefined) { 273 | if (!v) { 274 | delete this.fields.webServiceURL; 275 | return; 276 | } 277 | 278 | // validating URL, it will throw on bad value 279 | const url = v instanceof URL ? v : new URL(v); 280 | const allowHttp = this.options?.allowHttp ?? false; 281 | if (!allowHttp && url.protocol !== 'https:') { 282 | throw new TypeError(`webServiceURL must be on HTTPS!`); 283 | } 284 | this.fields.webServiceURL = v; 285 | } 286 | 287 | /** 288 | * The authentication token to use with the web service. 289 | * The token must be 16 characters or longer. 290 | */ 291 | get authenticationToken(): string | undefined { 292 | return this.fields.authenticationToken; 293 | } 294 | set authenticationToken(v: string | undefined) { 295 | if (!v) { 296 | delete this.fields.authenticationToken; 297 | return; 298 | } 299 | 300 | if (typeof v !== 'string') 301 | throw new TypeError( 302 | `authenticationToken must be a string, received ${typeof v}`, 303 | ); 304 | if (v.length < 16) 305 | throw new TypeError( 306 | `authenticationToken must must be 16 characters or longer`, 307 | ); 308 | this.fields.authenticationToken = v; 309 | } 310 | 311 | /** 312 | * Background color of the pass, specified as an CSS-style RGB triple. 313 | * 314 | * @example rgb(23, 187, 82) 315 | */ 316 | get backgroundColor(): 317 | | [number, number, number] 318 | | string 319 | | undefined 320 | | PassColor { 321 | if (!(this.fields.backgroundColor instanceof PassColor)) return undefined; 322 | return this.fields.backgroundColor; 323 | } 324 | set backgroundColor( 325 | v: string | [number, number, number] | undefined | PassColor, 326 | ) { 327 | if (!v) { 328 | delete this.fields.backgroundColor; 329 | return; 330 | } 331 | if (!(this.fields.backgroundColor instanceof PassColor)) 332 | this.fields.backgroundColor = new PassColor(v); 333 | else this.fields.backgroundColor.set(v); 334 | } 335 | 336 | /** 337 | * Foreground color of the pass, specified as a CSS-style RGB triple. 338 | * 339 | * @example rgb(100, 10, 110) 340 | */ 341 | get foregroundColor(): 342 | | [number, number, number] 343 | | string 344 | | undefined 345 | | PassColor { 346 | if (!(this.fields.foregroundColor instanceof PassColor)) return undefined; 347 | return this.fields.foregroundColor; 348 | } 349 | set foregroundColor( 350 | v: string | [number, number, number] | PassColor | undefined, 351 | ) { 352 | if (!v) { 353 | delete this.fields.foregroundColor; 354 | return; 355 | } 356 | if (!(this.fields.foregroundColor instanceof PassColor)) 357 | this.fields.foregroundColor = new PassColor(v); 358 | else this.fields.foregroundColor.set(v); 359 | } 360 | 361 | /** 362 | * Color of the label text, specified as a CSS-style RGB triple. 363 | * 364 | * @example rgb(255, 255, 255) 365 | */ 366 | get labelColor(): [number, number, number] | string | undefined | PassColor { 367 | if (!(this.fields.labelColor instanceof PassColor)) return undefined; 368 | return this.fields.labelColor; 369 | } 370 | set labelColor(v: string | [number, number, number] | PassColor | undefined) { 371 | if (!v) { 372 | delete this.fields.labelColor; 373 | return; 374 | } 375 | if (!(this.fields.labelColor instanceof PassColor)) 376 | this.fields.labelColor = new PassColor(v); 377 | else this.fields.labelColor.set(v); 378 | } 379 | 380 | /** 381 | * Color of the strip text, specified as a CSS-style RGB triple. 382 | * 383 | * @example rgb(255, 255, 255) 384 | */ 385 | get stripColor(): [number, number, number] | string | undefined | PassColor { 386 | if (!(this.fields.stripColor instanceof PassColor)) return undefined; 387 | return this.fields.stripColor; 388 | } 389 | set stripColor(v: string | [number, number, number] | PassColor | undefined) { 390 | if (!v) { 391 | delete this.fields.stripColor; 392 | return; 393 | } 394 | if (!(this.fields.stripColor instanceof PassColor)) 395 | this.fields.stripColor = new PassColor(v); 396 | else this.fields.stripColor.set(v); 397 | } 398 | 399 | /** 400 | * Maximum distance in meters from a relevant latitude and longitude that the pass is relevant. 401 | * This number is compared to the pass’s default distance and the smaller value is used. 402 | */ 403 | get maxDistance(): number | undefined { 404 | return this.fields.maxDistance; 405 | } 406 | set maxDistance(v: number | undefined) { 407 | if (!v) { 408 | delete this.fields.maxDistance; 409 | return; 410 | } 411 | if (!Number.isInteger(v)) 412 | throw new TypeError( 413 | 'maxDistance must be a positive integer distance in meters!', 414 | ); 415 | this.fields.maxDistance = v; 416 | } 417 | 418 | /** 419 | * Beacons marking locations where the pass is relevant. 420 | */ 421 | get beacons(): ApplePass['beacons'] { 422 | return this.fields.beacons; 423 | } 424 | set beacons(v: ApplePass['beacons']) { 425 | if (!v || !Array.isArray(v)) { 426 | delete this.fields.beacons; 427 | return; 428 | } 429 | for (const beacon of v) { 430 | if (!beacon.proximityUUID) 431 | throw new TypeError(`each beacon must contain proximityUUID`); 432 | } 433 | // copy array 434 | this.fields.beacons = [...v]; 435 | } 436 | 437 | /** 438 | * Information specific to the pass’s barcode. 439 | * The system uses the first valid barcode dictionary in the array. 440 | * Additional dictionaries can be added as fallbacks. 441 | */ 442 | get barcodes(): ApplePass['barcodes'] { 443 | return this.fields.barcodes; 444 | } 445 | set barcodes(v: ApplePass['barcodes']) { 446 | if (!v) { 447 | delete this.fields.barcodes; 448 | delete this.fields.barcode; 449 | return; 450 | } 451 | 452 | if (!Array.isArray(v)) 453 | throw new TypeError(`barcodes must be an Array, received ${typeof v}`); 454 | 455 | // Barcodes dictionary: https://developer.apple.com/library/content/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/LowerLevel.html#//apple_ref/doc/uid/TP40012026-CH3-SW3 456 | for (const barcode of v) { 457 | if (!BARCODES_FORMAT.has(barcode.format)) 458 | throw new TypeError( 459 | `Barcode format value ${barcode.format} is invalid!`, 460 | ); 461 | if (typeof barcode.message !== 'string') 462 | throw new TypeError('Barcode message string is required'); 463 | if (typeof barcode.messageEncoding !== 'string') 464 | throw new TypeError('Barcode messageEncoding is required'); 465 | } 466 | 467 | // copy array 468 | this.fields.barcodes = [...v]; 469 | } 470 | 471 | /** 472 | * Adds a location where a pass is relevant. 473 | * 474 | * @param {number[] | { lat: number, lng: number, alt?: number } | { longitude: number, latitude: number, altitude?: number }} point 475 | * @param {string} [relevantText] 476 | * @returns {this} 477 | */ 478 | addLocation( 479 | point: 480 | | number[] 481 | | { lat: number; lng: number; alt?: number } 482 | | { longitude: number; latitude: number; altitude?: number }, 483 | relevantText?: string, 484 | ): this { 485 | const { longitude, latitude, altitude } = getGeoPoint(point); 486 | const location: import('../interfaces').Location = { 487 | longitude, 488 | latitude, 489 | }; 490 | if (altitude) location.altitude = altitude; 491 | if (typeof relevantText === 'string') location.relevantText = relevantText; 492 | if (!Array.isArray(this.fields.locations)) 493 | this.fields.locations = [location]; 494 | else this.fields.locations.push(location); 495 | return this; 496 | } 497 | 498 | get locations(): ApplePass['locations'] { 499 | return this.fields.locations; 500 | } 501 | 502 | set locations(v: ApplePass['locations']) { 503 | delete this.fields.locations; 504 | if (!v) return; 505 | if (!Array.isArray(v)) throw new TypeError(`locations must be an array`); 506 | else 507 | for (const location of v) 508 | this.addLocation(location, location.relevantText); 509 | } 510 | } 511 | -------------------------------------------------------------------------------- /src/lib/fieldsMap.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Field, FieldDescriptor, DataStyleFormat } from '../interfaces'; 4 | 5 | import { getW3CDateString } from './w3cdate'; 6 | 7 | export class FieldsMap extends Map { 8 | /** 9 | * Returns Map as array of objects with key moved inside like a key property 10 | */ 11 | toJSON(): Field[] | undefined { 12 | if (!this.size) return undefined; 13 | return [...this].map( 14 | ([key, data]): Field => { 15 | // Remap Date objects to string 16 | if (data.value instanceof Date) 17 | data.value = getW3CDateString(data.value); 18 | return { key, ...data }; 19 | }, 20 | ); 21 | } 22 | 23 | /** 24 | * Adds a field to the end of the list 25 | * 26 | * @param {Field} field - Field key or object with all fields 27 | * @returns {FieldsMap} 28 | * @memberof FieldsMap 29 | */ 30 | add(field: Field): this { 31 | const { key, ...data } = field; 32 | if (typeof key !== 'string') 33 | throw new TypeError( 34 | `To add a field you must provide string key value, received ${typeof key}`, 35 | ); 36 | if (!('value' in data)) 37 | throw new TypeError( 38 | `To add a field you must provide a value field, received: ${JSON.stringify( 39 | data, 40 | )}`, 41 | ); 42 | if ('dateStyle' in data) { 43 | const date = 44 | data.value instanceof Date ? data.value : new Date(data.value); 45 | if (!Number.isFinite(date.getTime())) 46 | throw new TypeError( 47 | `When dateStyle specified the value must be a valid Date instance or string, received ${data.value}`, 48 | ); 49 | this.set(key, { ...data, value: date }); 50 | } else this.set(key, data); 51 | return this; 52 | } 53 | 54 | /** 55 | * Sets value field for a given key, without changing the rest of field properties 56 | * 57 | * @param {string} key 58 | * @param {string} value 59 | * @memberof FieldsMap 60 | */ 61 | setValue(key: string, value: string): this { 62 | if (typeof key !== 'string') 63 | throw new TypeError( 64 | `key for setValue must be a string, received ${typeof key}`, 65 | ); 66 | if (typeof value !== 'string') 67 | throw new TypeError( 68 | `value for setValue must be a string, received ${typeof value}`, 69 | ); 70 | const field = this.get(key) || { value }; 71 | field.value = value; 72 | this.set(key, field); 73 | return this; 74 | } 75 | 76 | /** 77 | * Set a field as Date value with appropriated options 78 | * 79 | * @param {string} key 80 | * @param {string} label 81 | * @param {Date} date 82 | * @param {{dateStyle?: string, ignoresTimeZone?: boolean, isRelative?: boolean, timeStyle?:string, changeMessage?: string}} [formatOptions] 83 | * @returns {FieldsMap} 84 | * @throws if date is not a Date or invalid Date 85 | * @memberof FieldsMap 86 | */ 87 | setDateTime( 88 | key: string, 89 | label: string, 90 | date: Date, 91 | { 92 | dateStyle, 93 | ignoresTimeZone, 94 | isRelative, 95 | timeStyle, 96 | changeMessage, 97 | }: { 98 | dateStyle?: DataStyleFormat; 99 | ignoresTimeZone?: boolean; 100 | isRelative?: boolean; 101 | timeStyle?: DataStyleFormat; 102 | changeMessage?: string; 103 | } = {}, 104 | ): this { 105 | if (typeof key !== 'string') 106 | throw new TypeError(`Key must be a string, received ${typeof key}`); 107 | if (typeof label !== 'string') 108 | throw new TypeError(`Label must be a string, received ${typeof label}`); 109 | if (!(date instanceof Date)) 110 | throw new TypeError( 111 | 'Third parameter of setDateTime must be an instance of Date', 112 | ); 113 | // Either specify both a date style and a time style, or neither. 114 | if (!!dateStyle !== !!timeStyle) 115 | throw new ReferenceError( 116 | 'Either specify both a date style and a time style, or neither', 117 | ); 118 | // adding 119 | this.set(key, { 120 | label, 121 | value: date, 122 | changeMessage, 123 | dateStyle, 124 | ignoresTimeZone, 125 | isRelative, 126 | timeStyle, 127 | }); 128 | 129 | return this; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/lib/get-geo-point.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns normalized geo point object from geoJSON, {lat, lng} or {lattitude,longutude,altitude} 3 | * 4 | * @param {number[] | { lat: number, lng: number, alt?: number } | { longitude: number, latitude: number, altitude?: number }} point 5 | * @returns {{ longitude: number, latitude: number, altitude?: number }} 6 | * @throws on unknown point format 7 | */ 8 | export function getGeoPoint( 9 | point: 10 | | readonly number[] 11 | | { lat: number; lng: number; alt?: number } 12 | | { longitude: number; latitude: number } & ( 13 | | { altitude?: number } 14 | | { elevation?: number }), 15 | ): { longitude: number; latitude: number; altitude?: number } { 16 | if (!point) throw new Error("Can't get coordinates from undefined"); 17 | 18 | // GeoJSON Array [longitude, latitude(, elevation)] 19 | if (Array.isArray(point)) { 20 | if (point.length < 2 || !point.every(n => Number.isFinite(n))) 21 | throw new Error( 22 | `Invalid GeoJSON array of numbers, length must be 2 to 3, received ${point.length}`, 23 | ); 24 | return { 25 | longitude: point[0], 26 | latitude: point[1], 27 | altitude: point[2], 28 | }; 29 | } 30 | 31 | // it can be an object with both lat and lng properties 32 | if ('lat' in point && 'lng' in point) { 33 | return { 34 | longitude: point.lng, 35 | latitude: point.lat, 36 | altitude: point.alt, 37 | }; 38 | } 39 | 40 | if ('longitude' in point && 'latitude' in point) { 41 | // returning a copy 42 | return { 43 | longitude: point.longitude, 44 | latitude: point.latitude, 45 | altitude: 46 | 'altitude' in point 47 | ? point.altitude 48 | : 'elevation' in point 49 | ? point.elevation 50 | : undefined, 51 | }; 52 | } 53 | 54 | // If we are here it means we can't understand what a hell is it 55 | throw new Error(`Unknown geo point format: ${JSON.stringify(point)}`); 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/getBufferHash.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { createHash } from 'crypto'; 4 | 5 | /** 6 | * 7 | * @param {Buffer} buffer 8 | * @returns {string} 9 | */ 10 | export function getBufferHash(buffer: Buffer | string): string { 11 | // creating hash 12 | const sha = createHash('sha1'); 13 | sha.update(buffer); 14 | return sha.digest('hex'); 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/images.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base PassImages class to add image filePath manipulation 3 | */ 4 | 5 | 'use strict'; 6 | 7 | import { promisify } from 'util'; 8 | import * as path from 'path'; 9 | import { createReadStream, promises as fs } from 'fs'; 10 | 11 | import * as imagesize from 'imagesize'; 12 | 13 | import { IMAGES, DENSITIES } from '../constants'; 14 | 15 | import { normalizeLocale } from './normalize-locale'; 16 | 17 | interface ImageSizeResult { 18 | format: 'gif' | 'png' | 'jpeg'; 19 | width: number; 20 | height: number; 21 | } 22 | 23 | const imageSize: ( 24 | v: import('stream').Readable, 25 | ) => Promise = promisify(imagesize); 26 | 27 | export type ImageDensity = '1x' | '2x' | '3x'; 28 | export type ImageType = 29 | | 'logo' 30 | | 'icon' 31 | | 'background' 32 | | 'footer' 33 | | 'strip' 34 | | 'thumbnail'; 35 | 36 | const IMAGES_TYPES = new Set(Object.keys(IMAGES)); 37 | export const IMAGE_FILENAME_REGEX = new RegExp( 38 | `(^|/)((?[-A-Z_a-z]+).lproj/)?(?${Object.keys(IMAGES).join( 39 | '|', 40 | )}+)(@(?[23]x))?.png$`, 41 | ); 42 | 43 | export class PassImages extends Map { 44 | constructor(images?: PassImages) { 45 | super(images instanceof PassImages ? [...images] : undefined); 46 | } 47 | 48 | async toArray(): Promise<{ path: string; data: Buffer }[]> { 49 | return Promise.all( 50 | [...this].map(async ([filepath, pathOrBuffer]) => ({ 51 | path: filepath, 52 | data: 53 | typeof pathOrBuffer === 'string' 54 | ? await fs.readFile(pathOrBuffer) 55 | : pathOrBuffer, 56 | })), 57 | ); 58 | } 59 | 60 | /** 61 | * Checks that all required images is set or throws elsewhere 62 | */ 63 | validate(): void { 64 | const keys = [...this.keys()]; 65 | // Check for required images 66 | for (const requiredImage of ['icon', 'logo']) 67 | if (!keys.some(img => img.endsWith(`${requiredImage}.png`))) 68 | throw new SyntaxError(`Missing required image ${requiredImage}.png`); 69 | } 70 | 71 | /** 72 | * Load all images from the specified directory. Only supported images are 73 | * loaded, nothing bad happens if directory contains other files. 74 | * 75 | * @param {string} dirPath - path to a directory with images 76 | * @memberof PassImages 77 | */ 78 | async load(dirPath: string): Promise { 79 | // Check if the path is accessible directory actually 80 | const entries = await fs.readdir(dirPath, { withFileTypes: true }); 81 | // checking rest of files 82 | const entriesLoader: Promise[] = []; 83 | for (const entry of entries) { 84 | if (entry.isDirectory()) { 85 | // check if it's a localization folder 86 | const test = /(?[-A-Z_a-z]+)\.lproj/.exec(entry.name); 87 | if (!test?.groups?.lang) continue; 88 | const { lang } = test.groups; 89 | // reading this directory 90 | const currentPath = path.join(dirPath, entry.name); 91 | const localizations = await fs.readdir(currentPath, { 92 | withFileTypes: true, 93 | }); 94 | // check if we have any localized images 95 | for (const f of localizations) { 96 | const img = this.parseFilename(f.name); 97 | if (img) 98 | entriesLoader.push( 99 | this.add( 100 | img.imageType, 101 | path.join(currentPath, f.name), 102 | img.density, 103 | lang, 104 | ), 105 | ); 106 | } 107 | } else { 108 | // check it it's an image 109 | const img = this.parseFilename(entry.name); 110 | if (img) 111 | entriesLoader.push( 112 | this.add( 113 | img.imageType, 114 | path.join(dirPath, entry.name), 115 | img.density, 116 | ), 117 | ); 118 | } 119 | } 120 | await Promise.all(entriesLoader); 121 | return this; 122 | } 123 | 124 | async add( 125 | imageType: ImageType, 126 | pathOrBuffer: string | Buffer, 127 | density?: ImageDensity, 128 | lang?: string, 129 | ): Promise { 130 | if (!IMAGES_TYPES.has(imageType)) 131 | throw new TypeError(`Unknown image type ${imageSize} for ${imageType}`); 132 | if (density && !DENSITIES.has(density)) 133 | throw new TypeError(`Invalid density ${density} for ${imageType}`); 134 | 135 | // check data 136 | let sizeRes; 137 | if (typeof pathOrBuffer === 'string') { 138 | // PNG size is in first 24 bytes 139 | const rs = createReadStream(pathOrBuffer, { highWaterMark: 30 }); 140 | sizeRes = await imageSize(rs); 141 | // see https://github.com/nodejs/node/issues/25335#issuecomment-451945106 142 | rs.once('readable', () => rs.destroy()); 143 | } else { 144 | if (!Buffer.isBuffer(pathOrBuffer)) 145 | throw new TypeError( 146 | `Image data for ${imageType} must be either file path or buffer`, 147 | ); 148 | const { Parser } = imagesize; 149 | const parser = Parser(); 150 | const res = parser.parse(pathOrBuffer); 151 | if (res !== Parser.DONE) 152 | throw new TypeError( 153 | `Supplied buffer doesn't contain valid PNG image for ${imageType}`, 154 | ); 155 | sizeRes = parser.getResult() as ImageSizeResult; 156 | } 157 | this.checkImage(imageType, sizeRes, density); 158 | super.set(this.getImageFilename(imageType, density, lang), pathOrBuffer); 159 | } 160 | 161 | parseFilename( 162 | fileName: string, 163 | ): 164 | | { imageType: ImageType; density?: ImageDensity; lang?: string } 165 | | undefined { 166 | const test = IMAGE_FILENAME_REGEX.exec(fileName); 167 | if (!test?.groups) return undefined; 168 | const res: { 169 | imageType: ImageType; 170 | density?: ImageDensity; 171 | lang?: string; 172 | } = { imageType: test.groups.imageType as ImageType }; 173 | if (test.groups.density) res.density = test.groups.density as ImageDensity; 174 | if (test.groups.lang) res.lang = normalizeLocale(test.groups.lang); 175 | return res; 176 | } 177 | 178 | // eslint-disable-next-line complexity 179 | private checkImage( 180 | imageType: ImageType, 181 | sizeResult: ImageSizeResult, 182 | density?: ImageDensity, 183 | ): void { 184 | const densityMulti = density ? parseInt(density.charAt(0), 10) : 1; 185 | const { format, width, height } = sizeResult; 186 | if (format !== 'png') 187 | throw new TypeError(`Image for "${imageType}" is not a PNG file!`); 188 | if (!Number.isInteger(width) || width <= 0) 189 | throw new TypeError(`Image ${imageType} has invalid width: ${width}`); 190 | if (!Number.isInteger(height) || height <= 0) 191 | throw new TypeError(`Image ${imageType} has invalid height: ${height}`); 192 | /** 193 | * @see {@link https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html} 194 | */ 195 | switch (imageType) { 196 | case 'icon': 197 | if (width < 29 * densityMulti) 198 | throw new TypeError( 199 | `icon image must have width ${29 * 200 | densityMulti}px for ${densityMulti}x density`, 201 | ); 202 | if (height < 29 * densityMulti) 203 | throw new TypeError( 204 | `icon image must have height ${29 * 205 | densityMulti}px for ${densityMulti}x density`, 206 | ); 207 | break; 208 | 209 | case 'logo': 210 | if (width > 160 * densityMulti) 211 | throw new TypeError( 212 | `logo image must have width no large than ${160 * 213 | densityMulti}px for ${densityMulti}x density`, 214 | ); 215 | // if (height > 50 * densityMulti) 216 | // throw new TypeError( 217 | // `logo image must have height ${50 * 218 | // densityMulti}px for ${densityMulti}x density, received ${height}`, 219 | // ); 220 | break; 221 | 222 | case 'background': 223 | if (width > 180 * densityMulti) 224 | throw new TypeError( 225 | `background image must have width ${180 * 226 | densityMulti}px for ${densityMulti}x density`, 227 | ); 228 | if (height > 220 * densityMulti) 229 | throw new TypeError( 230 | `background image must have height ${220 * 231 | densityMulti}px for ${densityMulti}x density`, 232 | ); 233 | break; 234 | 235 | case 'footer': 236 | if (width > 286 * densityMulti) 237 | throw new TypeError( 238 | `footer image must have width ${286 * 239 | densityMulti}px for ${densityMulti}x density`, 240 | ); 241 | if (height > 15 * densityMulti) 242 | throw new TypeError( 243 | `footer image must have height ${15 * 244 | densityMulti}px for ${densityMulti}x density`, 245 | ); 246 | break; 247 | 248 | case 'strip': 249 | // if (width > 375 * densityMulti) 250 | // throw new TypeError( 251 | // `strip image must have width ${375 * 252 | // densityMulti}px for ${densityMulti}x density, received ${width}`, 253 | // ); 254 | if (height > 144 * densityMulti) 255 | throw new TypeError( 256 | `strip image must have height ${144 * 257 | densityMulti}px for ${densityMulti}x density`, 258 | ); 259 | break; 260 | 261 | case 'thumbnail': 262 | if (width > 120 * densityMulti) 263 | throw new TypeError( 264 | `thumbnail image must have width no large than ${90 * 265 | densityMulti}px for ${densityMulti}x density, received ${width}`, 266 | ); 267 | if (height > 150 * densityMulti) 268 | throw new TypeError( 269 | `thumbnail image must have height ${90 * 270 | densityMulti}px for ${densityMulti}x density, received ${height}`, 271 | ); 272 | break; 273 | } 274 | } 275 | 276 | private getImageFilename( 277 | imageType: ImageType, 278 | density?: ImageDensity, 279 | lang?: string, 280 | ): string { 281 | return `${lang ? `${normalizeLocale(lang)}.lproj/` : ''}${imageType}${ 282 | /^[23]x$/.test(density || '') ? `@${density}` : '' 283 | }.png`; 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/lib/localizations.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class to handle Apple pass localizations 3 | * 4 | * @see {@link @see https://apple.co/2M9LWVu} - String Resources 5 | * @see {@link https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html#//apple_ref/doc/uid/TP40012195-CH4-SW54} 6 | */ 7 | 8 | import { createReadStream, promises as fs } from 'fs'; 9 | import { createInterface } from 'readline'; 10 | import * as path from 'path'; 11 | import { EOL } from 'os'; 12 | 13 | import { normalizeLocale } from './normalize-locale'; 14 | 15 | /** 16 | * Just as in C, some characters must be prefixed with a backslash before you can include them in the string. 17 | * These characters include double quotation marks, the backslash character itself, 18 | * and special control characters such as linefeed (\n) and carriage returns (\r). 19 | * 20 | * @see {@link https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html#//apple_ref/doc/uid/10000051i-CH6-SW13} 21 | */ 22 | 23 | export function escapeString(str: string): string { 24 | return str 25 | .replace(/["\\]/g, '\\$&') // quote and backslash 26 | .split(EOL) 27 | .join('\\n'); // escape new lines 28 | } 29 | 30 | export function unescapeString(str: string): string { 31 | return str 32 | .split('\\n') // new line 33 | .join(EOL) 34 | .replace(/\\(["\\])/g, '$1'); // quote and backslash 35 | } 36 | 37 | async function readStringsFromStream( 38 | stream: import('stream').Readable, 39 | ): Promise> { 40 | const res = new Map() as Map; 41 | let nextLineIsComment = false; 42 | stream.setEncoding('utf-16le'); 43 | const rl = createInterface(stream); 44 | for await (const line of rl) { 45 | // skip empty lines 46 | const l = line.trim(); 47 | if (!l) continue; 48 | // check if starts with '/*' and skip comments 49 | if (nextLineIsComment || l.startsWith('/*')) { 50 | nextLineIsComment = !l.endsWith('*/'); 51 | continue; 52 | } 53 | // check for first quote, assignment operator, and final semicolon 54 | const test = /^"(?.+)"\s*=\s*"(?.+)"\s*;/.exec(l); 55 | if (!test) continue; 56 | const { msgId, msgStr } = test.groups as { msgId: string; msgStr: string }; 57 | res.set(unescapeString(msgId), unescapeString(msgStr)); 58 | } 59 | return res; 60 | } 61 | 62 | /** 63 | * @see {@link https://github.com/justinklemm/i18n-strings-files/blob/dae303ed60d9d43dbe1a39bb66847be8a0d62c11/index.coffee#L100} 64 | * @param {string} filename - path to pass.strings file 65 | */ 66 | export async function readLprojStrings( 67 | filename: string, 68 | ): Promise> { 69 | return readStringsFromStream( 70 | createReadStream(filename, { encoding: 'utf16le' }), 71 | ); 72 | } 73 | 74 | /** 75 | * Converts given translations map into UTF-16 encoded buffer in .lproj format 76 | * 77 | * @param {Map.} strings 78 | */ 79 | export function getLprojBuffer(strings: Map): Buffer { 80 | /** 81 | * Just as in C, some characters must be prefixed with a backslash before you can include them in the string. 82 | * These characters include double quotation marks, the backslash character itself, 83 | * and special control characters such as linefeed (\n) and carriage returns (\r). 84 | * 85 | * @see {@link https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html#//apple_ref/doc/uid/10000051i-CH6-SW13} 86 | */ 87 | return Buffer.from( 88 | '\ufeff' /* byte order mark - UTF16 LE */ + 89 | [...strings] 90 | .map( 91 | ([key, value]) => 92 | `"${escapeString(key)}" = "${escapeString(value)}";`, 93 | ) 94 | .join('\n'), // macOs compatible output for Buffer, so no EOL 95 | 'utf16le', 96 | ); 97 | } 98 | 99 | /** 100 | * Localizations class Map> 101 | */ 102 | export class Localizations extends Map> { 103 | constructor(v?: Localizations) { 104 | // copy localizations if provided 105 | super( 106 | v instanceof Localizations 107 | ? [...v].map(([lang, map]) => [lang, new Map([...map])]) 108 | : undefined, 109 | ); 110 | } 111 | 112 | /** 113 | * 114 | * @param {string} lang - ISO 3166 alpha-2 code for the language 115 | * @param {{ [k: string]?: string }} values 116 | */ 117 | add(lang: string, values: { [k: string]: string }): this { 118 | const locale = normalizeLocale(lang); 119 | const map: Map = this.get(locale) || new Map(); 120 | for (const [key, value] of Object.entries(values)) { 121 | map.set(key, value); 122 | } 123 | if (!this.has(lang)) this.set(locale, map); 124 | return this; 125 | } 126 | 127 | toArray(): { path: string; data: Buffer }[] { 128 | return [...this].map(([lang, map]) => ({ 129 | path: `${lang}.lproj/pass.strings`, 130 | data: getLprojBuffer(map), 131 | })); 132 | } 133 | 134 | async addFile(language: string, filename: string): Promise { 135 | this.set(normalizeLocale(language), await readLprojStrings(filename)); 136 | } 137 | 138 | async addFromStream( 139 | language: string, 140 | stream: import('stream').Readable, 141 | ): Promise { 142 | this.set(normalizeLocale(language), await readStringsFromStream(stream)); 143 | } 144 | 145 | /** 146 | * Loads available localizations from given folder path 147 | * 148 | * @param {string} dirPath 149 | */ 150 | async load(dirPath: string): Promise { 151 | const entries = await fs.readdir(dirPath, { withFileTypes: true }); 152 | const loaders: Promise[] = []; 153 | for (const entry of entries) { 154 | if (!entry.isDirectory()) continue; 155 | // check if it's a localization folder 156 | const test = /^(?[-A-Z_a-z]+)\.lproj$/.exec(entry.name); 157 | if (!test) continue; 158 | const { lang } = test.groups as { lang: string }; 159 | const currentPath = path.join(dirPath, entry.name); 160 | const localizations = await fs.readdir(currentPath, { 161 | withFileTypes: true, 162 | }); 163 | // check if it has strings and load 164 | if (localizations.find(f => f.isFile() && f.name === 'pass.strings')) 165 | loaders.push( 166 | this.addFile(lang, path.join(currentPath, 'pass.strings')), 167 | ); 168 | } 169 | await Promise.all(loaders); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/lib/nfc-fields.ts: -------------------------------------------------------------------------------- 1 | import * as forge from 'node-forge'; 2 | 3 | import { NFCDictionary } from '../interfaces'; 4 | 5 | /** 6 | * node-forge doesn't support ECDH used by Apple in NFC, 7 | * so we will store keys as PEM encoded strings 8 | * 9 | * @see {@link https://github.com/digitalbazaar/forge/issues/116} 10 | * @see {@link https://stackoverflow.com/questions/48438753/apple-wallet-nfc-encryptionpublickey} 11 | * @see {@link https://github.com/digitalbazaar/forge/issues/237} 12 | */ 13 | 14 | export class NFCField implements NFCDictionary { 15 | message = ''; 16 | encryptionPublicKey?: string; 17 | 18 | /** 19 | * 20 | */ 21 | 22 | constructor(nfc?: NFCDictionary) { 23 | if (!nfc) return; 24 | 25 | // this will check the PEM 26 | this.message = nfc.message; 27 | 28 | /** 29 | * The public encryption key used by the Value Added Services protocol. 30 | * Use a Base64 encoded X.509 SubjectPublicKeyInfo structure containing a ECDH public key for group P256. 31 | 32 | encryptionPublicKey ?: string; 33 | */ 34 | if (typeof nfc.encryptionPublicKey === 'string') 35 | this.encryptionPublicKey = nfc.encryptionPublicKey; 36 | } 37 | 38 | /** 39 | * Sets public key from PEM-encoded key or forge.pki.PublicKey instance 40 | * 41 | * @param {forge.pki.PublicKey | string} key 42 | * @returns {this} 43 | */ 44 | setPublicKey(key: forge.pki.PublicKey | string): this { 45 | const pemKey = 46 | typeof key === 'string' ? key : forge.pki.publicKeyToPem(key); 47 | // test PEM key type 48 | // decode throws on invalid PEM message 49 | const pem = forge.pem.decode(pemKey); 50 | const publicKey = pem.find(({ type }) => type === 'PUBLIC KEY'); 51 | // ensure it have a public key 52 | if (!publicKey) 53 | throw new TypeError( 54 | `NFC publicKey must be a PEM encoded X.509 SubjectPublicKeyInfo string`, 55 | ); 56 | 57 | const der = forge.pki.pemToDer(pemKey); 58 | const oid = forge.asn1.derToOid(der); 59 | /** 60 | * Ensure it's ECDH 61 | * 62 | * @see {@link https://www.alvestrand.no/objectid/1.2.840.10045.2.1.html} 63 | */ 64 | if (!oid.includes('840.10045.2.1')) 65 | throw new TypeError(`Public key must be a ECDH public key`); 66 | 67 | this.encryptionPublicKey = Buffer.from(publicKey.body, 'binary').toString( 68 | 'base64', 69 | ); 70 | 71 | return this; 72 | } 73 | 74 | toJSON(): NFCDictionary | undefined { 75 | if (!this.message) return undefined; 76 | const res: NFCDictionary = { message: this.message }; 77 | if (this.encryptionPublicKey) 78 | res.encryptionPublicKey = this.encryptionPublicKey; 79 | return res; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/normalize-locale.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see {@link https://stackoverflow.com/questions/8758340/regex-to-detect-locales} 3 | */ 4 | 5 | export function normalizeLocale(locale: string): string { 6 | const match = /^(?[A-Za-z]{2,4})([-_](?[A-Za-z]{4}|\d{3}))?([-_](?[A-Za-z]{2}|\d{3}))?$/.exec( 7 | locale, 8 | ); 9 | if (!match?.groups?.lang) 10 | throw new TypeError(`Invalid locale string: ${locale}`); 11 | let result = match.groups.lang.toLowerCase(); 12 | if (match.groups.variant) 13 | result += `-${match.groups.variant 14 | .charAt(0) 15 | .toUpperCase()}${match.groups.variant.slice(1).toLowerCase()}`; 16 | if (match.groups.country) result += `-${match.groups.country.toUpperCase()}`; 17 | return result; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/pass-color.ts: -------------------------------------------------------------------------------- 1 | import * as colorNames from 'color-name'; 2 | 3 | const ABBR_RE = /^#([\da-f])([\da-f])([\da-f])([\da-f])?$/i; 4 | const HEX_RE = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})?$/i; 5 | const PERCENT_RE = /^rgba?\(\s*([+-]?[\d.]+)%\s*,\s*([+-]?[\d.]+)%\s*,\s*([+-]?[\d.]+)%\s*(?:,\s*([+-]?[\d.]+)\s*)?\)$/i; 6 | const RGBA_RE = /^rgba?\(\s*(1?\d{1,2}|2[0-4]\d|25[0-5])\s*,\s*(1?\d{1,2}|2[0-4]\d|25[0-5])\s*,\s*(1?\d{1,2}|2[0-4]\d|25[0-5])\s*(?:,\s*([+-]?[\d.]+)\s*)?\)$/i; 7 | 8 | function is0to255(num: number): boolean { 9 | if (!Number.isInteger(num)) return false; 10 | return num >= 0 && num <= 255; 11 | } 12 | 13 | /** 14 | * Converts given string into RGB array 15 | * 16 | * @param {string} colorString - color string, like 'blue', "#FFF", "rgba(200, 60, 60, 0.3)", "rgb(200, 200, 200)", "rgb(0%, 0%, 100%)" 17 | */ 18 | function getRgb(colorString: string): [number, number, number] { 19 | // short paths 20 | const string = colorString.trim(); 21 | if (string in colorNames) return colorNames[string]; 22 | if (/transparent/i.test(string)) return [0, 0, 0]; 23 | 24 | // we don't need to recheck values because they are enforced by regexes 25 | let match = ABBR_RE.exec(string); 26 | if (match) { 27 | return match.slice(1, 4).map(c => parseInt(c + c, 16)) as [ 28 | number, 29 | number, 30 | number, 31 | ]; 32 | } 33 | if ((match = HEX_RE.exec(string))) { 34 | return match.slice(1, 4).map(v => parseInt(v, 16)) as [ 35 | number, 36 | number, 37 | number, 38 | ]; 39 | } 40 | if ((match = RGBA_RE.exec(string))) { 41 | return match.slice(1, 4).map(c => parseInt(c, 10)) as [ 42 | number, 43 | number, 44 | number, 45 | ]; 46 | } 47 | if ((match = PERCENT_RE.exec(string))) { 48 | return match.slice(1, 4).map(c => { 49 | const r = Math.round(parseFloat(c) * 2.55); 50 | if (is0to255(r)) return r; 51 | throw new TypeError( 52 | `Invalid color value "${colorString}": value ${c}% (${r}) is not between 0 and 255`, 53 | ); 54 | }) as [number, number, number]; 55 | } 56 | 57 | throw new TypeError( 58 | `Invalid color value "${colorString}": unknown format - must be something like 'blue', "#FFF", "rgba(200, 60, 60, 0.3)", "rgb(200, 200, 200)", "rgb(0%, 0%, 100%)"`, 59 | ); 60 | } 61 | 62 | /** 63 | * returns current value as [r,g,b] array, but stringifies to JSON as string 'rgb(r, g, b)' 64 | */ 65 | export class PassColor extends Array { 66 | constructor(v?: string | [number, number, number] | PassColor) { 67 | super(); 68 | if (v) this.set(v); 69 | } 70 | 71 | set(v: string | PassColor | [number, number, number]): this { 72 | this.length = 0; 73 | if (Array.isArray(v)) { 74 | if (v.length < 3 || v.length > 4) 75 | throw new TypeError( 76 | `RGB colors array must have length 3 or 4, received ${v.length}`, 77 | ); 78 | // copying first 3 numbers to our array 79 | for (let i = 0, n = v[i]; i < 3; n = v[++i]) { 80 | if (!is0to255(n)) 81 | throw new TypeError( 82 | `RGB colors array must consist only integers between 0 and 255, received ${JSON.stringify( 83 | v, 84 | )}`, 85 | ); 86 | super.push(n); 87 | } 88 | } else if (typeof v === 'string') { 89 | super.push(...getRgb(v)); 90 | } 91 | return this; 92 | } 93 | 94 | toJSON(): string | undefined { 95 | if (this.length !== 3) return undefined; 96 | return `rgb(${this.join(', ')})`; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/lib/pass-structure.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-depth */ 2 | /** 3 | * Class that implements base structure fields setters / getters 4 | * 5 | * @see {@link https://developer.apple.com/library/content/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/LowerLevel.html#//apple_ref/doc/uid/TP40012026-CH3-SW3} 6 | */ 7 | 8 | import { 9 | ApplePass, 10 | PassStyle, 11 | TransitType, 12 | PassCommonStructure, 13 | } from '../interfaces'; 14 | import { PASS_STYLES, TRANSIT, STRUCTURE_FIELDS } from '../constants'; 15 | 16 | import { FieldsMap } from './fieldsMap'; 17 | import { NFCField } from './nfc-fields'; 18 | 19 | export class PassStructure { 20 | protected fields: Partial = {}; 21 | 22 | // eslint-disable-next-line sonarjs/cognitive-complexity 23 | constructor(fields: Partial = {}) { 24 | // setting style first 25 | for (const style of PASS_STYLES) { 26 | if (style in fields) { 27 | this.style = style; 28 | if ('boardingPass' in fields && fields.boardingPass) { 29 | this.transitType = fields.boardingPass.transitType; 30 | } else if ('storeCard' in this.fields && 'nfc' in fields) { 31 | // check NFC fields 32 | this.fields.nfc = new NFCField(fields.nfc); 33 | } 34 | const structure: PassCommonStructure = fields[this.style]; 35 | for (const prop of STRUCTURE_FIELDS) { 36 | if (prop in structure) { 37 | const currentProperty = structure[prop]; 38 | if (Array.isArray(currentProperty)) 39 | for (const field of currentProperty) this[prop].add(field); 40 | else if (currentProperty instanceof FieldsMap) 41 | // copy fields 42 | for (const [key, data] of currentProperty) 43 | this[prop].add({ key, ...data }); 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * Pass type, e.g boardingPass, coupon, etc 52 | */ 53 | get style(): PassStyle | undefined { 54 | for (const style of PASS_STYLES) { 55 | if (style in this.fields) return style; 56 | } 57 | return undefined; 58 | } 59 | 60 | set style(v: PassStyle | undefined) { 61 | // remove all other styles 62 | for (const style of PASS_STYLES) if (style !== v) delete this.fields[style]; 63 | if (!v) return; 64 | if (!PASS_STYLES.has(v)) throw new TypeError(`Invalid Pass type "${v}"`); 65 | if (!(v in this.fields)) this.fields[v] = {}; 66 | // Add NFC fields 67 | if ('storeCard' in this.fields) this.fields.nfc = new NFCField(); 68 | // if ('boardingPass' in this.fields && this.fields.boardingPass) this.fields.boardingPass. 69 | } 70 | 71 | /** 72 | * Required for boarding passes; otherwise not allowed. 73 | * Type of transit. 74 | * Must be one of the following values: PKTransitTypeAir, PKTransitTypeBoat, PKTransitTypeBus, PKTransitTypeGeneric,PKTransitTypeTrain. 75 | */ 76 | get transitType(): TransitType | undefined { 77 | if (this.style !== 'boardingPass') 78 | throw new ReferenceError( 79 | `transitType field only allowed in Boarding Passes, current pass is ${this.style}`, 80 | ); 81 | if ('boardingPass' in this.fields && this.fields.boardingPass) 82 | return this.fields.boardingPass.transitType; 83 | return undefined; 84 | } 85 | 86 | set transitType(v: TransitType | undefined) { 87 | const { style } = this; 88 | if (!style) { 89 | // removing transitType on empty pass does nothing 90 | if (!v) return; 91 | // setting transitStyle on a pass without type will set this pass as boardingPass also 92 | this.style = 'boardingPass'; 93 | } 94 | if (!('boardingPass' in this.fields)) 95 | throw new ReferenceError( 96 | `transitType field is only allowed at boarding passes`, 97 | ); 98 | 99 | if (!v) { 100 | if (this.fields.boardingPass) delete this.fields.boardingPass.transitType; 101 | } else { 102 | if (Object.values(TRANSIT).includes(v)) { 103 | if (this.fields.boardingPass) this.fields.boardingPass.transitType = v; 104 | else this.fields.boardingPass = { transitType: v }; 105 | } else throw new TypeError(`Unknown transit type "${v}"`); 106 | } 107 | } 108 | 109 | /** 110 | * NFC-enabled pass keys support sending reward card information as part of an Apple Pay transaction. 111 | * 112 | * NFC-enabled pass keys are only supported in passes that contain an Enhanced Passbook/NFC certificate. 113 | * For more information, contact merchant support at https://developer.apple.com/contact/passkit/. 114 | * **Only for storeCards with special Apple approval** 115 | * 116 | * @see {@link https://developer.apple.com/library/archive/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/TopLevel.html#//apple_ref/doc/uid/TP40012026-CH2-DontLinkElementID_3} 117 | */ 118 | get nfc(): NFCField { 119 | if (!('storeCard' in this.fields)) 120 | throw new ReferenceError( 121 | `NFC fields only available for storeCard passes, current is ${this.style}`, 122 | ); 123 | return this.fields.nfc as NFCField; 124 | } 125 | 126 | get headerFields(): FieldsMap { 127 | const { style } = this; 128 | if (!style) 129 | throw new ReferenceError( 130 | `Pass style is undefined, set the pass style before accessing pass structure fields`, 131 | ); 132 | if (!(this.fields[style].headerFields instanceof FieldsMap)) 133 | this.fields[style].headerFields = new FieldsMap(); 134 | return this.fields[style].headerFields; 135 | } 136 | get auxiliaryFields(): FieldsMap { 137 | const { style } = this; 138 | if (!style) 139 | throw new ReferenceError( 140 | `Pass style is undefined, set the pass style before accessing pass structure fields`, 141 | ); 142 | if (!(this.fields[style].auxiliaryFields instanceof FieldsMap)) 143 | this.fields[style].auxiliaryFields = new FieldsMap(); 144 | return this.fields[style].auxiliaryFields; 145 | } 146 | get backFields(): FieldsMap { 147 | const { style } = this; 148 | if (!style) 149 | throw new ReferenceError( 150 | `Pass style is undefined, set the pass style before accessing pass structure fields`, 151 | ); 152 | if (!(this.fields[style].backFields instanceof FieldsMap)) 153 | this.fields[style].backFields = new FieldsMap(); 154 | return this.fields[style].backFields; 155 | } 156 | get primaryFields(): FieldsMap { 157 | const { style } = this; 158 | if (!style) 159 | throw new ReferenceError( 160 | `Pass style is undefined, set the pass style before accessing pass structure fields`, 161 | ); 162 | if (!(this.fields[style].primaryFields instanceof FieldsMap)) 163 | this.fields[style].primaryFields = new FieldsMap(); 164 | return this.fields[style].primaryFields; 165 | } 166 | get secondaryFields(): FieldsMap { 167 | const { style } = this; 168 | if (!style) 169 | throw new ReferenceError( 170 | `Pass style is undefined, set the pass style before accessing pass structure fields`, 171 | ); 172 | if (!(this.fields[style].secondaryFields instanceof FieldsMap)) 173 | this.fields[style].secondaryFields = new FieldsMap(); 174 | return this.fields[style].secondaryFields; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/lib/signManifest-forge.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as forge from 'node-forge'; 4 | 5 | const APPLE_CA_CERTIFICATE = forge.pki.certificateFromPem( 6 | process.env.APPLE_WWDR_CERT_PEM || 7 | `-----BEGIN CERTIFICATE----- 8 | MIIEIjCCAwqgAwIBAgIIAd68xDltoBAwDQYJKoZIhvcNAQEFBQAwYjELMAkGA1UE 9 | BhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsTHUFwcGxlIENlcnRp 10 | ZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBSb290IENBMB4XDTEz 11 | MDIwNzIxNDg0N1oXDTIzMDIwNzIxNDg0N1owgZYxCzAJBgNVBAYTAlVTMRMwEQYD 12 | VQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3JsZHdpZGUgRGV2ZWxv 13 | cGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3aWRlIERldmVsb3Bl 14 | ciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3 15 | DQEBAQUAA4IBDwAwggEKAoIBAQDKOFSmy1aqyCQ5SOmM7uxfuH8mkbw0U3rOfGOA 16 | YXdkXqUHI7Y5/lAtFVZYcC1+xG7BSoU+L/DehBqhV8mvexj/avoVEkkVCBmsqtsq 17 | Mu2WY2hSFT2Miuy/axiV4AOsAX2XBWfODoWVN2rtCbauZ81RZJ/GXNG8V25nNYB2 18 | NqSHgW44j9grFU57Jdhav06DwY3Sk9UacbVgnJ0zTlX5ElgMhrgWDcHld0WNUEi6 19 | Ky3klIXh6MSdxmilsKP8Z35wugJZS3dCkTm59c3hTO/AO0iMpuUhXf1qarunFjVg 20 | 0uat80YpyejDi+l5wGphZxWy8P3laLxiX27Pmd3vG2P+kmWrAgMBAAGjgaYwgaMw 21 | HQYDVR0OBBYEFIgnFwmpthhgi+zruvZHWcVSVKO3MA8GA1UdEwEB/wQFMAMBAf8w 22 | HwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wLgYDVR0fBCcwJTAjoCGg 23 | H4YdaHR0cDovL2NybC5hcHBsZS5jb20vcm9vdC5jcmwwDgYDVR0PAQH/BAQDAgGG 24 | MBAGCiqGSIb3Y2QGAgEEAgUAMA0GCSqGSIb3DQEBBQUAA4IBAQBPz+9Zviz1smwv 25 | j+4ThzLoBTWobot9yWkMudkXvHcs1Gfi/ZptOllc34MBvbKuKmFysa/Nw0Uwj6OD 26 | Dc4dR7Txk4qjdJukw5hyhzs+r0ULklS5MruQGFNrCk4QttkdUGwhgAqJTleMa1s8 27 | Pab93vcNIx0LSiaHP7qRkkykGRIZbVf1eliHe2iK5IaMSuviSRSqpd1VAKmuu0sw 28 | ruGgsbwpgOYJd+W+NKIByn/c4grmO7i77LpilfMFY0GCzQ87HUyVpNur+cmV6U/k 29 | TecmmYHpvPm0KdIBembhLoz2IYrF+Hjhga6/05Cdqa3zr/04GpZnMBxRpVzscYqC 30 | tGwPDBUf 31 | -----END CERTIFICATE-----`, 32 | ); 33 | 34 | /** 35 | * Signs a manifest and returns the signature. 36 | * 37 | * @param {import('node-forge').pki.Certificate} certificate - signing certificate 38 | * @param {import('node-forge').pki.PrivateKey} key - certificate password 39 | * @param {string} manifest - manifest to sign 40 | * @returns {Buffer} - signature for given manifest 41 | */ 42 | export function signManifest( 43 | certificate: forge.pki.Certificate, 44 | key: forge.pki.PrivateKey, 45 | manifest: string, 46 | ): Buffer { 47 | // create PKCS#7 signed data 48 | const p7 = forge.pkcs7.createSignedData(); 49 | p7.content = manifest; 50 | p7.addCertificate(certificate); 51 | p7.addCertificate(APPLE_CA_CERTIFICATE); 52 | p7.addSigner({ 53 | key: forge.pki.privateKeyToPem(key), 54 | certificate, 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 | // value will be auto-populated at signing time 64 | }, 65 | { 66 | type: forge.pki.oids.signingTime, 67 | // value will be auto-populated at signing time 68 | // value: new Date('2050-01-01T00:00:00Z') 69 | }, 70 | ], 71 | }); 72 | 73 | /** 74 | * Creating a detached signature because we don't need the signed content. 75 | */ 76 | p7.sign({ detached: true }); 77 | 78 | return Buffer.from(forge.asn1.toDer(p7.toAsn1()).getBytes(), 'binary'); 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/stream-to-buffer.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | 3 | /** 4 | * Converts readableStream into a Buffer 5 | * 6 | * @param {Readable} readableStream 7 | * @returns {Promise} 8 | */ 9 | export async function streamToBuffer( 10 | readableStream: Readable, 11 | ): Promise { 12 | const buf = []; 13 | for await (const data of readableStream) { 14 | buf.push(data); 15 | } 16 | return Buffer.concat(buf); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/w3cdate.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Checks if given string is a valid W3C date representation 5 | * 6 | * @param {string} dateStr 7 | * @returns {boolean} 8 | */ 9 | export function isValidW3CDateString(dateStr: string): boolean { 10 | if (typeof dateStr !== 'string') return false; 11 | // W3C date format with optional seconds 12 | return /^20\d{2}-[01]\d-[0-3]\dT[0-5]\d:[0-5]\d(:[0-5]\d)?(Z|([+-][01]\d:[03]0)$)/.test( 13 | dateStr, 14 | ); 15 | } 16 | 17 | /** 18 | * Converts given string or Date instance into valid W3C date string 19 | * 20 | * @param {string | Date} value 21 | * @throws if given string can't be converted into w3C date 22 | * @returns {string} 23 | */ 24 | export function getW3CDateString(value: string | Date): string { 25 | if (typeof value !== 'string' && !(value instanceof Date)) 26 | throw new TypeError('Argument must be either a string or Date object'); 27 | if (typeof value === 'string' && isValidW3CDateString(value)) return value; 28 | 29 | const date = value instanceof Date ? value : new Date(value); 30 | // creating W3C date (we will always do without seconds) 31 | const month = (1 + date.getMonth()).toFixed().padStart(2, '0'); 32 | const day = date 33 | .getDate() 34 | .toFixed() 35 | .padStart(2, '0'); 36 | const hours = date 37 | .getHours() 38 | .toFixed() 39 | .padStart(2, '0'); 40 | const minutes = date 41 | .getMinutes() 42 | .toFixed() 43 | .padStart(2, '0'); 44 | const offset = -date.getTimezoneOffset(); 45 | const offsetHours = Math.abs(Math.floor(offset / 60)) 46 | .toFixed() 47 | .padStart(2, '0'); 48 | const offsetMinutes = (Math.abs(offset) - parseInt(offsetHours, 10) * 60) 49 | .toFixed() 50 | .padStart(2, '0'); 51 | const offsetSign = offset < 0 ? '-' : '+'; 52 | return `${date.getFullYear()}-${month}-${day}T${hours}:${minutes}${offsetSign}${offsetHours}:${offsetMinutes}`; 53 | } 54 | 55 | export function getDateFromW3CString(value: string): Date { 56 | if (!isValidW3CDateString(value)) 57 | throw new TypeError(`Date string ${value} is now a valid W3C date string`); 58 | const res = /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2})(?[+-])(?\d{2}):(?\d{2})/.exec( 59 | value, 60 | ); 61 | if (!res) 62 | throw new TypeError(`Date string ${value} is now a valid W3C date string`); 63 | const { 64 | year, 65 | month, 66 | day, 67 | hours, 68 | mins, 69 | tzSign, 70 | tzHour, 71 | tzMin, 72 | } = res.groups as { 73 | year: string; 74 | month: string; 75 | day: string; 76 | hours: string; 77 | mins: string; 78 | tzSign: '+' | '-'; 79 | tzHour: string; 80 | tzMin: string; 81 | }; 82 | let utcdate = Date.UTC( 83 | parseInt(year, 10), 84 | parseInt(month, 10) - 1, // months are zero-offset (!) 85 | parseInt(day, 10), 86 | parseInt(hours, 10), 87 | parseInt(mins, 10), // hh:mm 88 | ); // optional fraction 89 | // utcdate is milliseconds since the epoch 90 | const offsetMinutes = parseInt(tzHour, 10) * 60 + parseInt(tzMin, 10); 91 | utcdate += (tzSign === '+' ? -1 : +1) * offsetMinutes * 60000; 92 | return new Date(utcdate); 93 | } 94 | -------------------------------------------------------------------------------- /src/lib/yazul-promisified.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | 3 | import { Entry, Options, ZipFile, fromBuffer as ZipFromBuffer } from 'yauzl'; 4 | import { EventIterator } from 'event-iterator'; 5 | 6 | import { streamToBuffer } from './stream-to-buffer'; 7 | 8 | // Promisifying yauzl 9 | Object.defineProperties(ZipFile.prototype, { 10 | [Symbol.asyncIterator]: { 11 | enumerable: true, 12 | writable: false, 13 | configurable: false, 14 | value() { 15 | return new EventIterator((push, stop, fail) => { 16 | this.addListener('entry', push); 17 | this.addListener('end', stop); 18 | this.addListener('error', fail); 19 | })[Symbol.asyncIterator](); 20 | }, 21 | }, 22 | openReadStreamAsync: { 23 | enumerable: true, 24 | writable: false, 25 | configurable: false, 26 | // eslint-disable-next-line @typescript-eslint/unbound-method 27 | value: promisify(ZipFile.prototype.openReadStream), 28 | }, 29 | getBuffer: { 30 | enumerable: true, 31 | writable: false, 32 | configurable: false, 33 | async value(entry: Entry) { 34 | const stream = await this.openReadStreamAsync(entry); 35 | return streamToBuffer(stream); 36 | }, 37 | }, 38 | }); 39 | export const unzipBuffer = (promisify(ZipFromBuffer) as unknown) as ( 40 | buffer: Buffer, 41 | options?: Options, 42 | ) => Promise< 43 | ZipFile & { 44 | openReadStreamAsync: (v: Entry) => Promise; 45 | getBuffer: (entry: Entry) => Promise; 46 | [Symbol.asyncIterator](): AsyncIterator; 47 | } 48 | >; 49 | -------------------------------------------------------------------------------- /src/pass.ts: -------------------------------------------------------------------------------- 1 | // Generate a pass file. 2 | 3 | 'use strict'; 4 | 5 | import { toBuffer as createZip } from 'do-not-zip'; 6 | 7 | import { getBufferHash } from './lib/getBufferHash'; 8 | import { PassImages } from './lib/images'; 9 | import { signManifest } from './lib/signManifest-forge'; 10 | import { PassBase } from './lib/base-pass'; 11 | import { ApplePass, Options } from './interfaces'; 12 | 13 | // Create a new pass. 14 | // 15 | // template - The template 16 | // fields - Pass fields (description, serialNumber, logoText) 17 | export class Pass extends PassBase { 18 | private readonly template: import('./template').Template; 19 | // eslint-disable-next-line max-params 20 | constructor( 21 | template: import('./template').Template, 22 | fields: Partial = {}, 23 | images?: PassImages, 24 | localization?: import('./lib/localizations').Localizations, 25 | options?: Options 26 | ) { 27 | super(fields, images, localization, options); 28 | this.template = template; 29 | 30 | Object.preventExtensions(this); 31 | } 32 | 33 | // Validate pass, throws error if missing a mandatory top-level field or image. 34 | validate(): void { 35 | // Check required top level fields 36 | for (const requiredField of [ 37 | 'description', 38 | 'organizationName', 39 | 'passTypeIdentifier', 40 | 'serialNumber', 41 | 'teamIdentifier', 42 | ]) 43 | if (!(requiredField in this.fields)) 44 | throw new ReferenceError(`${requiredField} is required in a Pass`); 45 | 46 | // authenticationToken && webServiceURL must be either both or none 47 | if ('webServiceURL' in this.fields) { 48 | if (typeof this.fields.authenticationToken !== 'string') 49 | throw new Error( 50 | 'While webServiceURL is present, authenticationToken also required!', 51 | ); 52 | if (this.fields.authenticationToken.length < 16) 53 | throw new ReferenceError( 54 | 'authenticationToken must be at least 16 characters long!', 55 | ); 56 | } else if ('authenticationToken' in this.fields) 57 | throw new TypeError( 58 | 'authenticationToken is presented in Pass data while webServiceURL is missing!', 59 | ); 60 | 61 | this.images.validate(); 62 | } 63 | 64 | /** 65 | * Returns Pass as a Buffer 66 | * 67 | * @memberof Pass 68 | * @returns {Promise.} 69 | */ 70 | async asBuffer(): Promise { 71 | // Validate before attempting to create 72 | this.validate(); 73 | if (!this.template.certificate) 74 | throw new ReferenceError( 75 | `Set pass certificate in template before producing pass buffers`, 76 | ); 77 | if (!this.template.key) 78 | throw new ReferenceError( 79 | `Set private key in pass template before producing pass buffers`, 80 | ); 81 | 82 | // Creating new Zip file 83 | const zip = [] as { path: string; data: Buffer | string }[]; 84 | 85 | // Adding required files 86 | // Create pass.json 87 | zip.push({ path: 'pass.json', data: Buffer.from(JSON.stringify(this)) }); 88 | 89 | // Localization 90 | zip.push(...this.localization.toArray()); 91 | 92 | // Images 93 | zip.push(...(await this.images.toArray())); 94 | 95 | // adding manifest 96 | // Construct manifest here 97 | const manifestJson = JSON.stringify( 98 | zip.reduce( 99 | (res, { path, data }) => { 100 | res[path] = getBufferHash(data); 101 | return res; 102 | }, 103 | {} as { [k: string]: string }, 104 | ), 105 | ); 106 | zip.push({ path: 'manifest.json', data: manifestJson }); 107 | 108 | // Create signature 109 | const signature = signManifest( 110 | this.template.certificate, 111 | this.template.key, 112 | manifestJson, 113 | ); 114 | zip.push({ path: 'signature', data: signature }); 115 | 116 | // finished! 117 | return createZip(zip); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/template.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Passbook are created from templates 3 | */ 4 | 5 | 'use strict'; 6 | 7 | import * as http2 from 'http2'; 8 | import { join } from 'path'; 9 | import { promises as fs } from 'fs'; 10 | 11 | import * as forge from 'node-forge'; 12 | import { unsigned as crc32 } from 'buffer-crc32'; 13 | 14 | import { Pass } from './pass'; 15 | import { PASS_STYLES } from './constants'; 16 | import { PassStyle, ApplePass, Options } from './interfaces'; 17 | import { PassBase } from './lib/base-pass'; 18 | import { unzipBuffer } from './lib/yazul-promisified'; 19 | 20 | import stripJsonComments = require('strip-json-comments'); 21 | 22 | const { 23 | HTTP2_HEADER_METHOD, 24 | HTTP2_HEADER_PATH, 25 | NGHTTP2_CANCEL, 26 | HTTP2_METHOD_POST, 27 | } = http2.constants; 28 | const { readFile, readdir } = fs; 29 | 30 | // Create a new template. 31 | // 32 | // style - Pass style (coupon, eventTicket, etc) 33 | // fields - Pass fields (passTypeIdentifier, teamIdentifier, etc) 34 | export class Template extends PassBase { 35 | key?: forge.pki.PrivateKey; 36 | certificate?: forge.pki.Certificate; 37 | private apn?: http2.ClientHttp2Session; 38 | 39 | // eslint-disable-next-line max-params 40 | constructor( 41 | style?: PassStyle, 42 | fields: Partial = {}, 43 | images?: import('./lib/images').PassImages, 44 | localization?: import('./lib/localizations').Localizations, 45 | options?: Options 46 | ) { 47 | super(fields, images, localization, options); 48 | 49 | if (style) { 50 | if (!PASS_STYLES.has(style)) 51 | throw new TypeError(`Unsupported pass style ${style}`); 52 | this.style = style; 53 | } 54 | } 55 | 56 | /** 57 | * Loads Template, images and key from a given path 58 | * 59 | * @static 60 | * @param {string} folderPath 61 | * @param {string} [keyPassword] - optional key password 62 | * @param {Options} options - settings for the lib 63 | * @returns {Promise.