mirror of
https://github.com/ARMSX2/Armsx2-Repo.git
synced 2026-08-24 16:52:58 -07:00
317 lines
9.5 KiB
JavaScript
317 lines
9.5 KiB
JavaScript
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),
|
||
|
|
};
|
||
|
|
};
|