Files
Armsx2-Repo/scripts/ipa-metadata.js
T

317 lines
9.5 KiB
JavaScript
Raw Normal View History

2026-06-01 23:28:04 +02:00
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { createRequire } from "node:module";
import { basename } from "node:path";
import { Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { bundleIdentifier } from "./constants.js";
import { SourceGenerationError } from "./errors.js";
import { publicAssetUrl } from "./source-utils.js";
const require = createRequire(import.meta.url);
const AdmZip = require("adm-zip");
const binaryPlistParser = require("bplist-parser");
const xmlPlistParser = require("plist");
const sha256ForFile = async (ipaFilePath) => {
const hash = createHash("sha256");
await pipeline(
createReadStream(ipaFilePath),
new Writable({
write(chunk, _encoding, callback) {
hash.update(chunk);
callback();
},
}),
);
return hash.digest("hex");
};
const mainInfoPlistEntry = (ipaArchive, ipaFilePath) => {
const matchingEntries = ipaArchive
.getEntries()
.filter((archiveEntry) =>
/^Payload\/[^/]+\.app\/Info\.plist$/u.test(archiveEntry.entryName.replaceAll("\\", "/")),
);
if (matchingEntries.length === 1) {
return matchingEntries[0];
}
if (matchingEntries.length > 1) {
throw new SourceGenerationError(`${basename(ipaFilePath)} contains multiple app Info.plist files.`);
}
throw new SourceGenerationError(`${basename(ipaFilePath)} is missing Payload/*.app/Info.plist.`);
};
const parseBinaryInfoPlist = (infoPlistBuffer) => {
const parsedPlists = binaryPlistParser.parseBuffer(infoPlistBuffer);
const parsedInfoPlist = parsedPlists?.[0];
if (!parsedInfoPlist || typeof parsedInfoPlist !== "object" || Array.isArray(parsedInfoPlist)) {
throw new SourceGenerationError("Binary Info.plist did not decode to an object.");
}
return parsedInfoPlist;
};
const parseXmlInfoPlist = (infoPlistBuffer) => {
const infoPlistXml = infoPlistBuffer.toString("utf8").replace(/^\uFEFF/u, "");
const parsedInfoPlist = xmlPlistParser.parse(infoPlistXml);
if (!parsedInfoPlist || typeof parsedInfoPlist !== "object" || Array.isArray(parsedInfoPlist)) {
throw new SourceGenerationError("XML Info.plist did not decode to an object.");
}
return parsedInfoPlist;
};
const parseInfoPlist = (infoPlistBuffer) => {
if (infoPlistBuffer.subarray(0, 6).toString("ascii") === "bplist") {
return parseBinaryInfoPlist(infoPlistBuffer);
}
return parseXmlInfoPlist(infoPlistBuffer);
};
const infoPlistString = (infoPlist, plistKey) => {
const plistValue = infoPlist[plistKey];
if (plistValue === undefined || plistValue === null) {
return null;
}
return String(plistValue).trim() || null;
};
const requiredInfoPlistString = (infoPlist, plistKey, ipaFileName) => {
const plistValue = infoPlistString(infoPlist, plistKey);
if (plistValue) {
return plistValue;
}
throw new SourceGenerationError(`${ipaFileName} is missing ${plistKey}.`);
};
const sourceDate = (isoTimestamp) => isoTimestamp.slice(0, 10);
const sourceTimestampFromStats = (fileStats) => {
const timestampMilliseconds = fileStats.birthtimeMs > 0
? fileStats.birthtimeMs
: fileStats.mtimeMs;
return new Date(timestampMilliseconds).toISOString();
};
const timestampFromDosTime = (dosTimeValue) => {
if (!Number.isSafeInteger(dosTimeValue)) {
return null;
}
const dosTime = dosTimeValue & 0xffff;
const dosDate = dosTimeValue >>> 16;
const year = 1980 + ((dosDate >>> 9) & 0x7f);
const month = (dosDate >>> 5) & 0xf;
const day = dosDate & 0x1f;
const hour = (dosTime >>> 11) & 0x1f;
const minute = (dosTime >>> 5) & 0x3f;
const second = (dosTime & 0x1f) * 2;
if (month < 1 || day < 1) {
return null;
}
return new Date(Date.UTC(year, month - 1, day, hour, minute, second)).toISOString();
};
const archiveEntryTimestamp = (archiveEntry) => {
const dosTimestamp = timestampFromDosTime(archiveEntry.header?.timeval);
if (dosTimestamp) {
return dosTimestamp;
}
const entryTime = archiveEntry.header?.time;
if (!(entryTime instanceof Date)) {
return null;
}
const entryTimestamp = entryTime.getTime();
if (!Number.isFinite(entryTimestamp)) {
return null;
}
return entryTime.toISOString();
};
const latestArchiveTimestamp = (ipaArchive) => {
const entryTimestamps = ipaArchive
.getEntries()
.map(archiveEntryTimestamp)
.filter(Boolean)
.sort();
return entryTimestamps.at(-1) ?? null;
};
const extractIpaMetadata = (ipaFilePath) => {
const ipaArchive = new AdmZip(ipaFilePath);
const infoPlistEntry = mainInfoPlistEntry(ipaArchive, ipaFilePath);
return {
infoPlist: parseInfoPlist(infoPlistEntry.getData()),
packageTimestamp: latestArchiveTimestamp(ipaArchive),
};
};
const privacyPermission = (infoPlist, plistKey, permissionType) => {
const usageDescription = infoPlistString(infoPlist, plistKey);
if (!usageDescription) {
return null;
}
return { type: permissionType, usageDescription };
};
const permissionMappings = [
["NSPhotoLibraryUsageDescription", "photos"],
["NSPhotoLibraryAddUsageDescription", "photos"],
["NSCameraUsageDescription", "camera"],
["NSLocationWhenInUseUsageDescription", "location"],
["NSLocationAlwaysAndWhenInUseUsageDescription", "location"],
["NSContactsUsageDescription", "contacts"],
["NSRemindersUsageDescription", "reminders"],
["NSAppleMusicUsageDescription", "music"],
["NSMediaLibraryUsageDescription", "music"],
["NSMicrophoneUsageDescription", "microphone"],
["NSSpeechRecognitionUsageDescription", "speech-recognition"],
["NSBluetoothAlwaysUsageDescription", "bluetooth"],
["NSBluetoothPeripheralUsageDescription", "bluetooth"],
["NSLocalNetworkUsageDescription", "network"],
["NSCalendarsUsageDescription", "calendars"],
["NSFaceIDUsageDescription", "faceid"],
["NSSiriUsageDescription", "siri"],
["NSMotionUsageDescription", "motion"],
];
const appPermissions = (infoPlist) => {
const permissionRecords = new Map();
for (const [plistKey, permissionType] of permissionMappings) {
if (permissionRecords.has(permissionType)) {
continue;
}
const permissionRecord = privacyPermission(infoPlist, plistKey, permissionType);
if (permissionRecord) {
permissionRecords.set(permissionType, permissionRecord);
}
}
return [...permissionRecords.values()];
};
const semanticVersionParts = (versionLabel) => {
const semanticMatch = String(versionLabel).match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/u);
if (!semanticMatch) {
return null;
}
const [, majorVersion, minorVersion = "0", patchVersion = "0", prereleaseLabel = ""] = semanticMatch;
return {
core: [majorVersion, minorVersion, patchVersion].map((versionSegment) => Number.parseInt(versionSegment, 10)),
prereleaseLabel,
};
};
const compareSemanticVersions = (leftVersion, rightVersion) => {
const leftParts = semanticVersionParts(leftVersion);
const rightParts = semanticVersionParts(rightVersion);
if (!leftParts || !rightParts) {
return String(leftVersion).localeCompare(String(rightVersion), undefined, { numeric: true });
}
for (const versionIndex of [0, 1, 2]) {
const versionDelta = leftParts.core[versionIndex] - rightParts.core[versionIndex];
if (versionDelta !== 0) {
return versionDelta;
}
}
if (leftParts.prereleaseLabel && !rightParts.prereleaseLabel) {
return -1;
}
if (!leftParts.prereleaseLabel && rightParts.prereleaseLabel) {
return 1;
}
return leftParts.prereleaseLabel.localeCompare(rightParts.prereleaseLabel, undefined, { numeric: true });
};
const compareBuildVersions = (leftBuildVersion, rightBuildVersion) =>
String(leftBuildVersion).localeCompare(String(rightBuildVersion), undefined, { numeric: true });
export const compareIpaManifests = (leftManifest, rightManifest) => {
const semanticDelta = compareSemanticVersions(rightManifest.version, leftManifest.version);
if (semanticDelta !== 0) {
return semanticDelta;
}
const buildDelta = compareBuildVersions(rightManifest.buildVersion, leftManifest.buildVersion);
if (buildDelta !== 0) {
return buildDelta;
}
return rightManifest.sourceTimestamp.localeCompare(leftManifest.sourceTimestamp);
};
export const ipaFileManifest = async (ipaFilePath, generatorOptions) => {
const ipaFileName = basename(ipaFilePath);
const fileStats = await stat(ipaFilePath);
const { infoPlist, packageTimestamp } = extractIpaMetadata(ipaFilePath);
const manifestTimestamp = packageTimestamp ?? sourceTimestampFromStats(fileStats);
const ipaBundleIdentifier = requiredInfoPlistString(infoPlist, "CFBundleIdentifier", ipaFileName);
if (ipaBundleIdentifier !== bundleIdentifier) {
throw new SourceGenerationError(
`${ipaFileName} reports ${ipaBundleIdentifier}; expected ${bundleIdentifier}.`,
);
}
const version = requiredInfoPlistString(infoPlist, "CFBundleShortVersionString", ipaFileName);
const buildVersion = requiredInfoPlistString(infoPlist, "CFBundleVersion", ipaFileName);
const downloadURL = publicAssetUrl(generatorOptions.baseUrl, `ipas/${ipaFileName}`);
return {
fileName: ipaFileName,
bundleIdentifier: ipaBundleIdentifier,
version,
buildVersion,
date: sourceDate(manifestTimestamp),
sourceTimestamp: manifestTimestamp,
downloadURL,
size: fileStats.size,
sha256: await sha256ForFile(ipaFilePath),
minOSVersion: infoPlistString(infoPlist, "MinimumOSVersion")
|| infoPlistString(infoPlist, "LSMinimumSystemVersion"),
maxOSVersion: infoPlistString(infoPlist, "MaximumOSVersion"),
permissions: appPermissions(infoPlist),
};
};