├── .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 | [](https://www.npmjs.com/package/@walletpass/pass-js) [](https://codecov.io/gh/walletpass/pass-js)
2 | [](https://snyk.io/test/github/walletpass/pass-js?targetFile=package.json) [](https://sonarcloud.io/dashboard?id=walletpass_pass-js) [](https://github.com/facebook/jest) [](https://packagephobia.now.sh/result?p=@walletpass/pass-js)
3 |
4 |
5 |
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