mirror of
https://github.com/marcogll/passkit-generator.git
synced 2026-03-15 17:25:21 +00:00
1150 lines
27 KiB
TypeScript
1150 lines
27 KiB
TypeScript
import { Stream } from "node:stream";
|
||
import { Buffer } from "node:buffer";
|
||
import path from "node:path";
|
||
import FieldsArray from "./FieldsArray";
|
||
import Bundle, { filesSymbol } from "./Bundle";
|
||
import getModelFolderContents from "./getModelFolderContents";
|
||
import * as Schemas from "./schemas";
|
||
import * as Signature from "./Signature";
|
||
import * as Strings from "./StringsUtils";
|
||
import * as Utils from "./utils";
|
||
import * as Messages from "./messages";
|
||
|
||
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 Event Ticket
|
||
* property `preferredStyleSchemes`.
|
||
*
|
||
* @throws if current type is not "eventTicket".
|
||
*/
|
||
|
||
public get preferredStyleSchemes(): Schemas.PreferredStyleSchemes {
|
||
if (this.type !== "eventTicket") {
|
||
throw new TypeError(
|
||
Messages.PREFERRED_STYLE_SCHEMES.UNEXPECTED_PASS_TYPE_GET,
|
||
);
|
||
}
|
||
|
||
return this[propsSymbol].preferredStyleSchemes;
|
||
}
|
||
|
||
/**
|
||
* Allows setting a preferredStyleSchemes property
|
||
* for a eventTicket.
|
||
*
|
||
* @throws if current type is not "eventTicket".
|
||
* @param value
|
||
*/
|
||
|
||
public set preferredStyleSchemes(value: Schemas.PreferredStyleSchemes) {
|
||
Utils.assertUnfrozen(this);
|
||
|
||
if (this.type !== "eventTicket") {
|
||
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 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.Field[] {
|
||
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.Field[] {
|
||
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.FieldWithRow[] {
|
||
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.Field[] {
|
||
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.Field[] {
|
||
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.Field[] {
|
||
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.Field,
|
||
),
|
||
primaryFields /*****/: new FieldsArray(
|
||
this,
|
||
sharedKeysPool,
|
||
Schemas.Field,
|
||
),
|
||
secondaryFields /***/: new FieldsArray(
|
||
this,
|
||
sharedKeysPool,
|
||
Schemas.Field,
|
||
),
|
||
auxiliaryFields /***/: new FieldsArray(
|
||
this,
|
||
sharedKeysPool,
|
||
type === "eventTicket" ? Schemas.FieldWithRow : Schemas.Field,
|
||
),
|
||
backFields /********/: new FieldsArray(
|
||
this,
|
||
sharedKeysPool,
|
||
Schemas.Field,
|
||
),
|
||
additionalInfoFields: new FieldsArray(
|
||
this,
|
||
sharedKeysPool,
|
||
Schemas.Field,
|
||
),
|
||
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.
|
||
*
|
||
* @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)) {
|
||
acc.push({
|
||
relevantDate: Utils.processDate(
|
||
new Date(entry.relevantDate),
|
||
),
|
||
});
|
||
|
||
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`). This method will
|
||
* set both the original, as the new one will get ignored in older
|
||
* iOS versions.
|
||
*/
|
||
|
||
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 {
|
||
return Object.prototype.hasOwnProperty.call(entry, "relevantDate");
|
||
}
|