Files
passkit-generator/src/PKPass.ts
2025-09-25 18:27:01 +02:00

1213 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Stream } from "node:stream";
import { Buffer } from "node:buffer";
import path from "node:path";
import FieldsArray from "./FieldsArray.js";
import Bundle, { filesSymbol } from "./Bundle.js";
import getModelFolderContents from "./getModelFolderContents.js";
import * as Schemas from "./schemas/index.js";
import * as Signature from "./Signature.js";
import * as Strings from "./StringsUtils.js";
import * as Utils from "./utils.js";
import * as Messages from "./messages.js";
const propsSymbol = Symbol("props");
const localizationSymbol = Symbol("pass.l10n");
const importMetadataSymbol = Symbol("import.pass.metadata");
const createManifestSymbol = Symbol("pass.manifest");
const closePassSymbol = Symbol("pass.close");
const passTypeSymbol = Symbol("pass.type");
const certificatesSymbol = Symbol("pass.certificates");
const RegExps = {
PASS_JSON: /pass\.json/,
MANIFEST_OR_SIGNATURE: /manifest|signature/,
PERSONALIZATION: {
JSON: /personalization\.json/,
LOGO: /personalizationLogo@(?:.{2})/,
} as const,
PASS_STRINGS: /(?<lang>[a-zA-Z-]{2,}).lproj\/pass\.strings/,
PASS_ICON: /icon(?:@\d{1}x)?/,
} as const;
export default class PKPass extends Bundle {
private [certificatesSymbol]: Schemas.CertificatesSchema;
private [propsSymbol]: Schemas.PassProps = {};
private [localizationSymbol]: {
[lang: string]: {
[placeholder: string]: string;
};
} = {};
private [passTypeSymbol]: Schemas.PassTypesProps | undefined = undefined;
/**
* Either create a pass from another one
* or a disk path.
*
* @param source
* @returns
*/
public static async from<S extends PKPass | Schemas.Template>(
source: S,
props?: Schemas.OverridablePassProps,
): Promise<PKPass> {
let certificates: Schemas.CertificatesSchema | undefined = undefined;
let buffers: Schemas.FileBuffers | undefined = undefined;
if (!source) {
throw new TypeError(
Messages.format(Messages.FROM.MISSING_SOURCE, source),
);
}
if (source instanceof PKPass) {
/** Cloning is happening here */
certificates = source[certificatesSymbol];
buffers = {};
const buffersEntries = Object.entries(source[filesSymbol]);
/** Cloning all the buffers to prevent unwanted edits */
for (let i = 0; i < buffersEntries.length; i++) {
const [fileName, contentBuffer] = buffersEntries[i];
buffers[fileName] = Buffer.alloc(contentBuffer.length);
contentBuffer.copy(buffers[fileName]);
}
/**
* Moving props to pass.json instead of overrides
* because many might get excluded when passing
* through validation
*/
buffers["pass.json"] = Buffer.from(
JSON.stringify(source[propsSymbol]),
);
} else {
Schemas.assertValidity(
Schemas.Template,
source,
Messages.TEMPLATE.INVALID,
);
buffers = await getModelFolderContents(source.model);
certificates = source.certificates;
}
return new PKPass(buffers, certificates, props);
}
/**
* Creates a Bundle made of PKPass to be distributed
* as a `.pkpasses` zip file. Returns a Bundle instance
* so it can be outputted both as stream or as a buffer.
*
* Using this will freeze all the instances passed as
* parameter.
*
* Throws if not all the files are instance of PKPass.
*
* @param passes
*/
public static pack(...passes: PKPass[]): Bundle {
const [bundle, freezeBundle] = Bundle.freezable(
"application/vnd.apple.pkpasses",
);
for (let i = 0; i < passes.length; i++) {
const pass = passes[i];
if (!(pass instanceof PKPass)) {
throw new Error(Messages.PACK.INVALID);
}
bundle.addBuffer(`packed-pass-${i + 1}.pkpass`, pass.getAsBuffer());
}
freezeBundle();
return bundle;
}
// **************** //
// *** INSTANCE *** //
// **************** //
public constructor(
buffers?: Schemas.FileBuffers,
certificates?: Schemas.CertificatesSchema,
props?: Schemas.OverridablePassProps,
) {
super("application/vnd.apple.pkpass");
if (buffers && typeof buffers === "object") {
const buffersEntries = Object.entries(buffers);
for (
let i = buffersEntries.length, buffer: [string, Buffer];
(buffer = buffersEntries[--i]);
) {
const [fileName, contentBuffer] = buffer;
this.addBuffer(fileName, contentBuffer);
}
} else {
console.warn(
Messages.format(Messages.INIT.INVALID_BUFFERS, typeof buffers),
);
}
if (props) {
/** Overrides validation and pushing in props */
const overridesValidation = Schemas.validate(
Schemas.OverridablePassProps,
props,
);
Object.assign(this[propsSymbol], overridesValidation);
}
if (certificates) {
this.certificates = certificates;
}
}
/**
* Allows changing the certificates, if needed.
* They are actually expected to be received in
* the constructor, but they can get overridden
* here for whatever purpose.
*
* When using this setter, all certificates are
* expected to be received, or an exception will
* be thrown.
*
* @param certs
*/
public set certificates(certs: Schemas.CertificatesSchema) {
Utils.assertUnfrozen(this);
Schemas.assertValidity(
Schemas.CertificatesSchema,
certs,
Messages.CERTIFICATES.INVALID,
);
this[certificatesSymbol] = certs;
}
/**
* Allows retrieving current languages
*/
public get languages() {
return Object.keys(this[localizationSymbol]);
}
/**
* Allows getting an image of the props
* that are composing your pass instance.
*/
public get props(): Schemas.PassProps {
return Utils.cloneRecursive(this[propsSymbol]);
}
/**
* Allows accessing to iOS 18 new property
* `preferredStyleSchemes`.
*
* @throws if current type is not "eventTicket" and is not "boardingPass".
*/
public get preferredStyleSchemes(): Schemas.PreferredStyleSchemes {
if (this.type !== "eventTicket" && this.type !== "boardingPass") {
throw new TypeError(
Messages.PREFERRED_STYLE_SCHEMES.UNEXPECTED_PASS_TYPE_GET,
);
}
return this[propsSymbol].preferredStyleSchemes;
}
/**
* Allows setting a preferredStyleSchemes property
* for a eventTicket. Use this to select
* either the Poster Event Tickets (iOS 18+) or the Semantic
* Boarding passes (iOS 26+).
*
* @throws if current type is not "eventTicket".
* @param value
*/
public set preferredStyleSchemes(value: Schemas.PreferredStyleSchemes) {
Utils.assertUnfrozen(this);
if (this.type !== "eventTicket" && this.type !== "boardingPass") {
throw new TypeError(
Messages.PREFERRED_STYLE_SCHEMES.UNEXPECTED_PASS_TYPE_SET,
);
}
Schemas.assertValidity(
Schemas.PreferredStyleSchemes,
value,
Messages.PREFERRED_STYLE_SCHEMES.INVALID,
);
this[propsSymbol].preferredStyleSchemes = value;
}
/**
* Allows setting UpcomingPassInformation for poster event tickets
* (iOS 26+).
*
* @throws if current type is not "eventTicket"
* @throws if preferredStyleSchemes is not set or does not include "posterEventTicket"
*/
public set upcomingPassInformation(
value: Schemas.UpcomingPassInformationEntry[],
) {
Utils.assertUnfrozen(this);
if (this.type !== "eventTicket") {
throw new TypeError(
Messages.UPCOMING_PASS_INFORMATION.UNEXPECTED_PASS_TYPE_SET,
);
}
if (!this.preferredStyleSchemes?.includes("posterEventTicket")) {
throw new TypeError(
Messages.UPCOMING_PASS_INFORMATION.UNEXPECTED_STYLE_SCHEME,
);
}
for (const entry of value) {
Schemas.assertValidity(
Schemas.UpcomingPassInformationEntry,
entry,
Messages.UPCOMING_PASS_INFORMATION.INVALID,
);
}
this[propsSymbol].upcomingPassInformation = value;
}
public get upcomingPassInformation(): Schemas.UpcomingPassInformationEntry[] {
if (this.type !== "eventTicket") {
throw new TypeError(
Messages.UPCOMING_PASS_INFORMATION.UNEXPECTED_PASS_TYPE_GET,
);
}
return this[propsSymbol].upcomingPassInformation || [];
}
/**
* Allows setting a transitType property
* for a boardingPass.
*
* @throws if current type is not "boardingPass".
* @param value
*/
public set transitType(value: Schemas.TransitType) {
Utils.assertUnfrozen(this);
if (this.type !== "boardingPass") {
throw new TypeError(Messages.TRANSIT_TYPE.UNEXPECTED_PASS_TYPE);
}
Schemas.assertValidity(
Schemas.TransitType,
value,
Messages.TRANSIT_TYPE.INVALID,
);
this[propsSymbol]["boardingPass"].transitType = value;
}
/**
* Allows getting the current transitType
* from pass props.
*
* @throws (automatically) if current type is not "boardingPass".
*/
public get transitType() {
return this[propsSymbol]["boardingPass"].transitType;
}
/**
* Allows accessing to primaryFields object.
*
* @throws (automatically) if no valid pass.json
* has been parsed yet or, anyway, if current
* instance has not a valid type set yet.
*/
public get primaryFields(): Schemas.PassFieldContent[] {
return this[propsSymbol][this.type].primaryFields;
}
/**
* Allows accessing to secondaryFields object
*
* @throws (automatically) if no valid pass.json
* has been parsed yet or, anyway, if current
* instance has not a valid type set yet.
*/
public get secondaryFields(): Schemas.PassFieldContent[] {
return this[propsSymbol][this.type].secondaryFields;
}
/**
* Allows accessing to auxiliaryFields object
*
* For Typescript users: this signature allows
* in any case to add the 'row' field, but on
* runtime they are only allowed on "eventTicket"
* passes.
*
* @throws (automatically) if no valid pass.json
* has been parsed yet or, anyway, if current
* instance has not a valid type set yet.
*/
public get auxiliaryFields(): Schemas.PassFieldContentWithRow[] {
return this[propsSymbol][this.type].auxiliaryFields;
}
/**
* Allows accessing to headerFields object
*
* @throws (automatically) if no valid pass.json
* has been parsed yet or, anyway, if current
* instance has not a valid type set yet.
*/
public get headerFields(): Schemas.PassFieldContent[] {
return this[propsSymbol][this.type].headerFields;
}
/**
* Allows accessing to backFields object
*
* @throws (automatically) if no valid pass.json
* has been parsed yet or, anyway, if current
* instance has not a valid type set yet.
*/
public get backFields(): Schemas.PassFieldContent[] {
return this[propsSymbol][this.type].backFields;
}
/**
* Allows accessing to new iOS 18
* event ticket additional fields
*
* @throws (automatically) if no valid pass.json
* has been parsed yet or, anyway, if current
* type is not "eventTicket".
*/
public get additionalInfoFields(): Schemas.PassFieldContent[] {
return this[propsSymbol]["eventTicket"].additionalInfoFields;
}
/**
* Allows setting a pass type.
*
* **Warning**: setting a type with this setter,
* will reset all the fields (primaryFields,
* secondaryFields, headerFields, auxiliaryFields, backFields),
* both imported or manually set.
*/
public set type(nextType: Schemas.PassTypesProps | undefined) {
Utils.assertUnfrozen(this);
Schemas.assertValidity(
Schemas.PassType,
nextType,
Messages.PASS_TYPE.INVALID,
);
/** Shut up, typescript strict mode! */
const type = nextType as Schemas.PassTypesProps;
if (this.type) {
/**
* Removing reference to previous type and its content because
* we might have some differences between types. It is way easier
* to reset everything instead of making checks.
*/
this[propsSymbol][this.type] = undefined;
this[propsSymbol].preferredStyleSchemes = undefined;
}
const sharedKeysPool = new Set<string>();
this[passTypeSymbol] = type;
this[propsSymbol][type] = {
headerFields /******/: new FieldsArray(
this,
sharedKeysPool,
Schemas.PassFieldContent,
),
primaryFields /*****/: new FieldsArray(
this,
sharedKeysPool,
Schemas.PassFieldContent,
),
secondaryFields /***/: new FieldsArray(
this,
sharedKeysPool,
Schemas.PassFieldContent,
),
auxiliaryFields /***/: new FieldsArray(
this,
sharedKeysPool,
type === "eventTicket"
? Schemas.PassFieldContentWithRow
: Schemas.PassFieldContent,
),
backFields /********/: new FieldsArray(
this,
sharedKeysPool,
Schemas.PassFieldContent,
),
additionalInfoFields: new FieldsArray(
this,
sharedKeysPool,
Schemas.PassFieldContent,
),
transitType: undefined,
};
}
public get type(): Schemas.PassTypesProps | undefined {
return this[passTypeSymbol] ?? undefined;
}
// **************************** //
// *** ASSETS SETUP METHODS *** //
// **************************** //
/**
* Allows adding a new asset inside the pass / bundle with
* the following exceptions:
*
* - Empty buffers are ignored;
* - `manifest.json` and `signature` files will be ignored;
* - `pass.json` will be read validated and merged in the
* current instance, if it wasn't added previously.
* It's properties will overwrite the instance ones.
* You might loose data;
* - `pass.strings` files will be read, parsed and merged
* with the current translations. Comments will be ignored;
* - `personalization.json` will be read, validated and added.
* They will be stripped out when exporting the pass if
* it won't have NFC details or if any of the personalization
* files is missing;
*
* @param pathName
* @param buffer
*/
public addBuffer(pathName: string, buffer: Buffer): void {
if (!buffer?.length) {
return;
}
if (RegExps.MANIFEST_OR_SIGNATURE.test(pathName)) {
return;
}
if (RegExps.PASS_JSON.test(pathName)) {
if (this[filesSymbol]["pass.json"]) {
/**
* Ignoring any further addition. In a
* future we might consider merging instead
*/
return;
}
try {
this[importMetadataSymbol](
validateJSONBuffer(buffer, Schemas.PassProps),
);
} catch (err) {
console.warn(
Messages.format(Messages.PASS_SOURCE.INVALID, err),
);
return;
}
/**
* Adding an empty buffer just for reference
* that we received a valid pass.json file.
* It will be reconciliated in export phase.
*/
return super.addBuffer(pathName, Buffer.alloc(0));
}
if (RegExps.PERSONALIZATION.JSON.test(pathName)) {
/**
* We are still allowing `personalizationLogo@XX.png`
* to be added to the bundle, but we'll delete it
* once the pass is getting closed, if needed.
*/
try {
validateJSONBuffer(buffer, Schemas.Personalize);
} catch (err) {
console.warn(
Messages.format(Messages.PERSONALIZE.INVALID, err),
);
return;
}
return super.addBuffer(pathName, buffer);
}
/**
* Converting Windows path to Unix path
* @example de.lproj\\icon.png => de.lproj/icon.png
*/
const normalizedPathName = pathName.replace(path.sep, "/");
/**
* If a new pass.strings file is added, we want to
* prevent it from being merged and, instead, save
* its translations for later
*/
let match: RegExpMatchArray | null;
if ((match = normalizedPathName.match(RegExps.PASS_STRINGS))) {
const [, lang] = match;
const parsedTranslations = Strings.parse(buffer).translations;
if (!parsedTranslations.length) {
return;
}
this.localize(lang, Object.fromEntries(parsedTranslations));
return;
}
return super.addBuffer(normalizedPathName, buffer);
}
/**
* Given data from a pass.json, reads them to bring them
* into the current pass instance.
*
* @param data
*/
private [importMetadataSymbol](data: Schemas.PassProps) {
const possibleTypes = [
"boardingPass",
"coupon",
"eventTicket",
"storeCard",
"generic",
] as Schemas.PassTypesProps[];
const type = possibleTypes.find((type) => Boolean(data[type]));
const {
boardingPass,
coupon,
storeCard,
generic,
eventTicket,
...otherPassData
} = data;
if (Object.keys(this[propsSymbol]).length) {
console.warn(Messages.PASS_SOURCE.JOIN);
}
Object.assign(this[propsSymbol], otherPassData);
if (!type) {
if (!this[passTypeSymbol]) {
console.warn(Messages.PASS_SOURCE.UNKNOWN_TYPE);
}
} else {
this.type = type;
const {
headerFields = [],
primaryFields = [],
secondaryFields = [],
auxiliaryFields = [],
backFields = [],
transitType,
additionalInfoFields = [],
} = data[type] || {};
this.headerFields.push(...headerFields);
this.primaryFields.push(...primaryFields);
this.secondaryFields.push(...secondaryFields);
this.auxiliaryFields.push(...auxiliaryFields);
this.backFields.push(...backFields);
if (this.type === "boardingPass") {
this.transitType = transitType;
}
if (this.type === "eventTicket") {
this.additionalInfoFields.push(...additionalInfoFields);
}
}
}
/**
* Creates the manifest starting from files
* added to the bundle
*/
private [createManifestSymbol](): Buffer {
const manifest = Object.entries(this[filesSymbol]).reduce<{
[key: string]: string;
}>(
(acc, [fileName, buffer]) => ({
...acc,
[fileName]: Signature.createHash(buffer),
}),
{},
);
return Buffer.from(JSON.stringify(manifest));
}
/**
* Applies the last validation checks against props,
* applies the props to pass.json and creates l10n
* files and folders and creates manifest and
* signature files
*/
private [closePassSymbol]() {
if (!this.type) {
throw new TypeError(Messages.CLOSE.MISSING_TYPE);
}
const fileNames = Object.keys(this[filesSymbol]);
const passJson = Buffer.from(JSON.stringify(this[propsSymbol]));
super.addBuffer("pass.json", passJson);
if (!fileNames.some((fileName) => RegExps.PASS_ICON.test(fileName))) {
console.warn(Messages.CLOSE.MISSING_ICON);
}
// *********************************** //
// *** LOCALIZATION FILES CREATION *** //
// *********************************** //
const localizationEntries = Object.entries(this[localizationSymbol]);
for (let i = localizationEntries.length - 1; i >= 0; i--) {
const [lang, translations] = localizationEntries[i];
const stringsFile = Strings.create(translations);
if (stringsFile.length) {
super.addBuffer(`${lang}.lproj/pass.strings`, stringsFile);
}
}
// *********************** //
// *** PERSONALIZATION *** //
// *********************** //
const meetsPersonalizationRequirements = Boolean(
this[propsSymbol]["nfc"] &&
this[filesSymbol]["personalization.json"] &&
fileNames.find((file) =>
RegExps.PERSONALIZATION.LOGO.test(file),
),
);
if (!meetsPersonalizationRequirements) {
/**
* Looking for every personalization file
* and removing it
*/
for (let i = 0; i < fileNames.length; i++) {
if (fileNames[i].includes("personalization")) {
console.warn(
Messages.format(
Messages.CLOSE.PERSONALIZATION_REMOVED,
fileNames[i],
),
);
delete this[filesSymbol][fileNames[i]];
}
}
}
// ******************************** //
// *** BOARDING PASS VALIDATION *** //
// ******************************** //
if (this.type === "boardingPass" && !this.transitType) {
throw new TypeError(Messages.CLOSE.MISSING_TRANSIT_TYPE);
}
// ****************************** //
// *** SIGNATURE AND MANIFEST *** //
// ****************************** //
const manifestBuffer = this[createManifestSymbol]();
super.addBuffer("manifest.json", manifestBuffer);
const signatureBuffer = Signature.create(
manifestBuffer,
this[certificatesSymbol],
);
super.addBuffer("signature", signatureBuffer);
}
// ************************* //
// *** EXPORTING METHODS *** //
// ************************* //
/**
* Exports the pass as a zip buffer. When this method
* is invoked, the bundle will get frozen and, thus,
* no files will be allowed to be added any further.
*
* @returns
*/
public getAsBuffer(): Buffer {
if (!this.isFrozen) {
this[closePassSymbol]();
}
return super.getAsBuffer();
}
/**
* Exports the pass as a zip stream. When this method
* is invoked, the bundle will get frozen and, thus,
* no files will be allowed to be added any further.
*
* @returns
*/
public getAsStream(): Stream {
if (!this.isFrozen) {
this[closePassSymbol]();
}
return super.getAsStream();
}
/**
* Exports the pass as a list of file paths and buffers.
* When this method is invoked, the bundle will get
* frozen and, thus, no files will be allowed to be
* added any further.
*
* This allows developers to choose a different way
* of serving, analyzing or zipping the file, outside the
* default compression system.
*
* @returns a frozen object containing files paths as key
* and Buffers as content.
*/
public getAsRaw(): { [filePath: string]: Buffer } {
if (!this.isFrozen) {
this[closePassSymbol]();
}
return super.getAsRaw();
}
// ************************** //
// *** DATA SETUP METHODS *** //
// ************************** //
/**
* Allows to add a localization details to the
* final bundle with some translations.
*
* If the language already exists, translations will be
* merged with the existing ones.
*
* Setting `translations` to `null` fully deletes a language,
* its translations and its files.
*
* @see https://developer.apple.com/documentation/walletpasses/creating_the_source_for_a_pass#3736718
* @param lang
* @param translations
*/
public localize(
lang: string,
translations: { [key: string]: string } | null,
) {
Utils.assertUnfrozen(this);
if (typeof lang !== "string") {
throw new TypeError(
Messages.format(Messages.LANGUAGES.INVALID_LANG, typeof lang),
);
}
if (translations === null) {
delete this[localizationSymbol][lang];
const allFilesKeys = Object.keys(this[filesSymbol]);
const langFolderIdentifier = `${lang}.lproj`;
for (let i = allFilesKeys.length - 1; i >= 0; i--) {
const filePath = allFilesKeys[i];
if (filePath.startsWith(langFolderIdentifier)) {
delete this[filesSymbol][filePath];
}
}
return;
}
if (!translations || !Object.keys(translations).length) {
console.warn(
Messages.format(Messages.LANGUAGES.NO_TRANSLATIONS, lang),
);
return;
}
this[localizationSymbol][lang] ??= {};
if (typeof translations === "object" && !Array.isArray(translations)) {
Object.assign(this[localizationSymbol][lang], translations);
}
}
/**
* Allows to specify an expiration date for the pass.
*
* Pass `null` to remove the expiration date.
*
* @param date
* @throws if pass is frozen due to previous export
* @returns
*/
public setExpirationDate(date: Date | null) {
Utils.assertUnfrozen(this);
if (date === null) {
delete this[propsSymbol]["expirationDate"];
return;
}
try {
this[propsSymbol]["expirationDate"] = Utils.processDate(date);
} catch (err) {
throw new TypeError(
Messages.format(Messages.DATE.INVALID, "expirationDate", date),
);
}
}
/**
* Allows setting some beacons the OS should
* react to and show this pass.
*
* Pass `null` to remove them at all.
*
* @example
* ```ts
* PKPassInstance.setBeacons(null)
* PKPassInstance.setBeacons({
* proximityUUID: "00000-000000-0000-00000000000",
* });
* ```
*
* @see https://developer.apple.com/documentation/walletpasses/pass/beacons
* @param beacons
* @throws if pass is frozen due to previous export
* @returns
*/
public setBeacons(beacons: null): void;
public setBeacons(...beacons: Schemas.Beacon[]): void;
public setBeacons(...beacons: (Schemas.Beacon | null)[]) {
Utils.assertUnfrozen(this);
if (beacons[0] === null) {
delete this[propsSymbol]["beacons"];
return;
}
this[propsSymbol]["beacons"] = Schemas.filterValid(
Schemas.Beacon,
beacons as Schemas.Beacon[],
);
}
/**
* Allows setting some locations the OS should
* react to and show this pass.
*
* Pass `null` to remove them at all.
*
* @example
* ```ts
* PKPassInstance.setLocations(null)
* PKPassInstance.setLocations({
* latitude: 0.5333245342
* longitude: 0.2135332252
* });
* ```
*
* @see https://developer.apple.com/documentation/walletpasses/pass/locations
* @param locations
* @throws if pass is frozen due to previous export
* @returns
*/
public setLocations(locations: null): void;
public setLocations(...locations: Schemas.Location[]): void;
public setLocations(...locations: (Schemas.Location | null)[]): void {
Utils.assertUnfrozen(this);
if (locations[0] === null) {
delete this[propsSymbol]["locations"];
return;
}
this[propsSymbol]["locations"] = Schemas.filterValid(
Schemas.Location,
locations as Schemas.Location[],
);
}
/**
* Allows setting a series of relevancy intervals or
* relevancy entries for the pass.
*
* Please note that `RelevantDate[]` `relevantDate` property was renamed
* in "date" since iOS 26. Since retro-compatibility is not ensured,
* this methods takes `date`, and fallbacks to `relevantDate`, and use
* the first value found for both properties.
*
* @param {Schemas.RelevantDate[] | null} relevancyEntries
* @returns {void}
*/
public setRelevantDates(
relevancyEntries: Schemas.RelevantDate[] | null,
): void {
Utils.assertUnfrozen(this);
if (relevancyEntries === null) {
this[propsSymbol]["relevantDates"] = undefined;
return;
}
const processedDateEntries = relevancyEntries.reduce<
Schemas.RelevantDate[]
>((acc, entry) => {
try {
Schemas.validate(Schemas.RelevantDate, entry);
if (isRelevantEntry(entry)) {
const date = Utils.processDate(
new Date(entry.date || entry.relevantDate),
);
acc.push({
relevantDate: date,
date,
});
return acc;
}
acc.push({
startDate: Utils.processDate(new Date(entry.startDate)),
endDate: Utils.processDate(new Date(entry.endDate)),
});
} catch (err) {
console.warn(new TypeError(Messages.RELEVANT_DATE.INVALID));
}
return acc;
}, []);
this[propsSymbol]["relevantDates"] = processedDateEntries;
}
/**
* Allows setting a relevant date in which the OS
* should show this pass.
*
* Pass `null` to remove relevant date from this pass.
*
* @param {Date | null} date
* @throws if pass is frozen due to previous export
*
* @warning `relevantDate` property has been deprecated in iOS 18
* in order to get replaced by `relevantDates` array of intervals
* (`relevantDates[].startDate` + `relevantDates[].endDate`)
* or single date (`relevantDates[].relevantDate`).
*/
public setRelevantDate(date: Date | null): void {
Utils.assertUnfrozen(this);
if (date === null) {
delete this[propsSymbol]["relevantDate"];
return;
}
try {
this[propsSymbol]["relevantDate"] = Utils.processDate(date);
} catch (err) {
throw new TypeError(
Messages.format(Messages.DATE.INVALID, "relevantDate", date),
);
}
}
/**
* Allows to specify some barcodes formats.
* As per the current specifications, only the first
* will be shown to the user, without any possibility
* to change it.
*
* It accepts either a string from which all formats will
* be generated or a specific set of barcodes objects
* to be validated and used.
*
* Pass `null` to remove all the barcodes.
*
* @see https://developer.apple.com/documentation/walletpasses/pass/barcodes
* @param barcodes
* @throws if pass is frozen due to previous export
* @returns
*/
public setBarcodes(barcodes: null): void;
public setBarcodes(message: string): void;
public setBarcodes(...barcodes: Schemas.Barcode[]): void;
public setBarcodes(...barcodes: (Schemas.Barcode | string | null)[]): void {
Utils.assertUnfrozen(this);
if (!barcodes.length) {
return;
}
if (barcodes[0] === null) {
delete this[propsSymbol]["barcodes"];
return;
}
let finalBarcodes: Schemas.Barcode[];
if (typeof barcodes[0] === "string") {
/**
* A string has been received instead of objects. We can
* only auto-fill them all with the same data.
*/
const supportedFormats: Array<Schemas.BarcodeFormat> = [
"PKBarcodeFormatQR",
"PKBarcodeFormatPDF417",
"PKBarcodeFormatAztec",
"PKBarcodeFormatCode128",
];
finalBarcodes = supportedFormats.map((format) =>
Schemas.validate(Schemas.Barcode, {
format,
message: barcodes[0],
} as Schemas.Barcode),
);
} else {
finalBarcodes = Schemas.filterValid(
Schemas.Barcode,
barcodes as Schemas.Barcode[],
);
}
this[propsSymbol]["barcodes"] = finalBarcodes;
}
/**
* Allows to specify details to make this, an
* NFC-capable pass.
*
* Pass `null` as parameter to remove it at all.
*
* @see https://developer.apple.com/documentation/walletpasses/pass/nfc
* @param data
* @throws if pass is frozen due to previous export
* @returns
*/
public setNFC(nfc: Schemas.NFC | null): void {
Utils.assertUnfrozen(this);
if (nfc === null) {
delete this[propsSymbol]["nfc"];
return;
}
this[propsSymbol]["nfc"] =
Schemas.validate(Schemas.NFC, nfc) ?? undefined;
}
}
function validateJSONBuffer(
buffer: Buffer,
schema: Parameters<typeof Schemas.validate>[0],
): Schemas.PassProps {
let contentAsJSON: Schemas.PassProps;
try {
contentAsJSON = JSON.parse(buffer.toString("utf8"));
} catch (err) {
throw new TypeError(Messages.JSON.INVALID);
}
return Schemas.validate(schema, contentAsJSON);
}
function isRelevantEntry(
entry: Schemas.RelevantDate,
): entry is Schemas.RelevancyEntry {
const isRelevantDateAvailable: boolean =
Object.prototype.hasOwnProperty.call(entry, "relevantDate") &&
"relevantDate" in entry;
const isDateAvailable: boolean =
Object.prototype.hasOwnProperty.call(entry, "date") && "date" in entry;
return isRelevantDateAvailable || isDateAvailable;
}