mirror of
https://github.com/ARMSX2/Armsx2-Repo.git
synced 2026-08-24 16:52:58 -07:00
Fix ARMSX2 iOS source generation reliability
This commit is contained in:
@@ -4,4 +4,6 @@ import { fileURLToPath } from "node:url";
|
||||
export const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
export const sourceIdentifier = "com.j1coding.armsx2.ios";
|
||||
export const bundleIdentifier = "com.armsx2.ios";
|
||||
export const defaultBaseUrl = "https://j1coding.github.io/Armsx2-Repo";
|
||||
export const canonicalBaseUrl = "https://ios.armsx2.net";
|
||||
export const canonicalSourceUrl = `${canonicalBaseUrl}/apps.json`;
|
||||
export const defaultBaseUrl = canonicalBaseUrl;
|
||||
|
||||
@@ -116,8 +116,8 @@ const runGenerator = async () => {
|
||||
await writeGeneratedTargets(targets, generated);
|
||||
|
||||
if (defaultOutputPaths(generatorOptions)) {
|
||||
for (const screenshotFile of generated.screenshotFiles) {
|
||||
await mirrorPublicAsset(screenshotFile);
|
||||
for (const assetFile of generated.assetFiles) {
|
||||
await mirrorPublicAsset(assetFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const releaseMatchesManifest = (githubRelease, manifest) => {
|
||||
githubRelease.name,
|
||||
].filter(Boolean).map(String);
|
||||
|
||||
const versionPattern = new RegExp(`(^|[^0-9])v?${escapedRegExp(manifest.version)}([^0-9]|$)`, "iu");
|
||||
const versionPattern = new RegExp(`(^|[^0-9])v?${escapedRegExp(manifest.version)}(?=$|[^0-9.])`, "iu");
|
||||
return releaseFields.some((releaseField) => versionPattern.test(releaseField));
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { canonicalBaseUrl, canonicalSourceUrl } from "./constants.js";
|
||||
|
||||
const expectedVersion = "1.3";
|
||||
const liveUrls = [
|
||||
canonicalSourceUrl,
|
||||
`${canonicalBaseUrl}/checksums.json`,
|
||||
`${canonicalBaseUrl}/ipas/ARMSX2-iOS-${expectedVersion}.ipa`,
|
||||
`${canonicalBaseUrl}/assets/icon.png`,
|
||||
];
|
||||
|
||||
const headCheck = async (url) => {
|
||||
const response = await fetch(url, { method: "HEAD", redirect: "follow" });
|
||||
|
||||
return {
|
||||
url,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
finalUrl: response.url,
|
||||
contentType: response.headers.get("content-type"),
|
||||
contentLength: response.headers.get("content-length"),
|
||||
};
|
||||
};
|
||||
|
||||
const jsonDocument = async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} returned ${response.status}.`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const runSmokeCheck = async () => {
|
||||
const results = [];
|
||||
|
||||
for (const liveUrl of liveUrls) {
|
||||
results.push(await headCheck(liveUrl));
|
||||
}
|
||||
|
||||
const sourceJson = await jsonDocument(canonicalSourceUrl);
|
||||
const sourceVersion = sourceJson.apps?.[0]?.versions?.[0]?.version;
|
||||
const downloadURL = sourceJson.apps?.[0]?.versions?.[0]?.downloadURL;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
expectedVersion,
|
||||
sourceVersion,
|
||||
downloadURL,
|
||||
checks: results,
|
||||
}, null, 2));
|
||||
|
||||
const failures = results
|
||||
.filter((result) => !result.ok)
|
||||
.map((result) => `${result.url} returned ${result.status}`);
|
||||
|
||||
if (sourceJson.sourceURL !== canonicalSourceUrl) {
|
||||
failures.push(`Live sourceURL is ${sourceJson.sourceURL}; expected ${canonicalSourceUrl}.`);
|
||||
}
|
||||
|
||||
if (sourceVersion !== expectedVersion) {
|
||||
failures.push(`Live version is ${sourceVersion}; expected ${expectedVersion}.`);
|
||||
}
|
||||
|
||||
if (downloadURL !== `${canonicalBaseUrl}/ipas/ARMSX2-iOS-${expectedVersion}.ipa`) {
|
||||
failures.push(`Live downloadURL is ${downloadURL}; expected ${canonicalBaseUrl}/ipas/ARMSX2-iOS-${expectedVersion}.ipa.`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(failures.join("\n"));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await runSmokeCheck();
|
||||
} catch (smokeError) {
|
||||
console.error(smokeError instanceof Error ? smokeError.message : smokeError);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const storeMetadata = async (generatorOptions) => {
|
||||
};
|
||||
|
||||
const imageExtensions = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
||||
const iconFile = "assets/icon.png";
|
||||
|
||||
const screenshotDirectory = (generatorOptions, metadataPayload) =>
|
||||
generatorOptions.screenshotDirectory
|
||||
@@ -97,7 +98,7 @@ const sourceApp = (ipaFileManifests, generatorOptions, metadataPayload, screensh
|
||||
subtitle: metadataPayload.app.subtitle ?? "Modern PlayStation 2 emulation for iOS.",
|
||||
localizedDescription: metadataPayload.app.localizedDescription
|
||||
?? "ARMSX2 brings PlayStation 2 emulation to iOS devices. Based on the open-source PCSX2 project, this ARM64-focused iOS build helps you revisit and preserve your own legally obtained PS2 game library on modern mobile hardware.",
|
||||
iconURL: publicAssetUrl(generatorOptions.baseUrl, "assets/icon.png"),
|
||||
iconURL: publicAssetUrl(generatorOptions.baseUrl, iconFile),
|
||||
screenshotURLs: screenshotFiles.map((screenshotFile) => publicAssetUrl(generatorOptions.baseUrl, screenshotFile)),
|
||||
tintColor: "#2F6FAD",
|
||||
versions: ipaFileManifests.map(sourceVersion),
|
||||
@@ -164,6 +165,7 @@ export const generatedBuffers = async (generatorOptions) => {
|
||||
return {
|
||||
source: jsonBuffer(sourcePayload(ipaFileManifests, generatorOptions, metadataPayload, screenshotFiles)),
|
||||
checksums: jsonBuffer(checksumPayload(ipaFileManifests, generatorOptions)),
|
||||
assetFiles: [iconFile, ...screenshotFiles],
|
||||
screenshotFiles,
|
||||
ipaCount: ipaFileManifests.length,
|
||||
};
|
||||
|
||||
+134
-13
@@ -19,6 +19,7 @@ import { promisify } from "node:util";
|
||||
|
||||
import { parseOptions, setOptionFlag, setOptionValue } from "./cli.js";
|
||||
import { defaultBaseUrl, repositoryRoot } from "./constants.js";
|
||||
import { ipaFileManifest } from "./ipa-metadata.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const generatorPath = resolve(repositoryRoot, "scripts/generate-source.js");
|
||||
@@ -113,25 +114,82 @@ const githubReleases = async (repositoryName) => {
|
||||
const releaseTimestamp = (githubRelease) =>
|
||||
String(githubRelease.published_at ?? githubRelease.created_at ?? "");
|
||||
|
||||
const versionFromIosRelease = (githubRelease) => {
|
||||
const releaseText = `${githubRelease.tag_name ?? ""} ${githubRelease.name ?? ""}`;
|
||||
return releaseText.match(/\biosv?(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)/iu)?.[1]
|
||||
?? releaseText.match(/\barmsx2-ios\D+(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)/iu)?.[1]
|
||||
?? null;
|
||||
};
|
||||
|
||||
const versionFromIosAsset = (releaseAsset) =>
|
||||
releaseAsset.name?.match(/^ARMSX2-iOS-v?(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)\.ipa$/iu)?.[1]
|
||||
?? null;
|
||||
|
||||
const candidateScore = (candidate) => {
|
||||
let score = 0;
|
||||
|
||||
if (candidate.releaseVersion) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
if (candidate.assetVersion) {
|
||||
score += 50;
|
||||
}
|
||||
|
||||
if (candidate.releaseVersion && candidate.releaseVersion === candidate.assetVersion) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
if (/^ARMSX2-iOS-v?\d/iu.test(candidate.releaseAsset.name ?? "")) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
};
|
||||
|
||||
const releaseCandidates = (releases, syncOptions) =>
|
||||
releases
|
||||
.filter((githubRelease) => !githubRelease.draft)
|
||||
.filter((githubRelease) => syncOptions.includePrereleases || !githubRelease.prerelease)
|
||||
.flatMap((githubRelease) =>
|
||||
(githubRelease.assets ?? [])
|
||||
.flatMap((githubRelease) => {
|
||||
const releaseVersion = versionFromIosRelease(githubRelease);
|
||||
|
||||
return (githubRelease.assets ?? [])
|
||||
.filter((releaseAsset) => releaseAsset.name?.toLowerCase().endsWith(".ipa"))
|
||||
.map((releaseAsset) => ({ githubRelease, releaseAsset })),
|
||||
)
|
||||
.sort((leftCandidate, rightCandidate) =>
|
||||
releaseTimestamp(rightCandidate.githubRelease).localeCompare(releaseTimestamp(leftCandidate.githubRelease)),
|
||||
);
|
||||
.map((releaseAsset) => ({
|
||||
githubRelease,
|
||||
releaseAsset,
|
||||
releaseVersion,
|
||||
assetVersion: versionFromIosAsset(releaseAsset),
|
||||
}));
|
||||
})
|
||||
.filter((candidate) => candidate.releaseVersion || candidate.assetVersion)
|
||||
.map((candidate) => ({ ...candidate, score: candidateScore(candidate) }))
|
||||
.sort((leftCandidate, rightCandidate) => {
|
||||
const scoreDelta = rightCandidate.score - leftCandidate.score;
|
||||
|
||||
if (scoreDelta !== 0) {
|
||||
return scoreDelta;
|
||||
}
|
||||
|
||||
return releaseTimestamp(rightCandidate.githubRelease).localeCompare(releaseTimestamp(leftCandidate.githubRelease));
|
||||
});
|
||||
|
||||
const selectedReleaseCandidate = (candidates) => {
|
||||
const iosCandidate = candidates.find(({ githubRelease, releaseAsset }) =>
|
||||
`${githubRelease.tag_name ?? ""} ${githubRelease.name ?? ""} ${releaseAsset.name ?? ""}`.match(/ios/i),
|
||||
);
|
||||
const [bestCandidate, nextCandidate] = candidates;
|
||||
|
||||
return iosCandidate ?? candidates[0] ?? null;
|
||||
if (!bestCandidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nextCandidate && nextCandidate.score === bestCandidate.score
|
||||
&& releaseTimestamp(nextCandidate.githubRelease) === releaseTimestamp(bestCandidate.githubRelease)) {
|
||||
throw new UpstreamSyncError(
|
||||
`Multiple equally good iOS IPA assets were found: ${bestCandidate.releaseAsset.name}, ${nextCandidate.releaseAsset.name}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return bestCandidate;
|
||||
};
|
||||
|
||||
const downloadAssetToFile = async (releaseAsset, outputPath) => {
|
||||
@@ -187,6 +245,35 @@ const writeGithubOutput = async (outputPayload) => {
|
||||
await writeFile(process.env.GITHUB_OUTPUT, `${outputLines}\n`, { flag: "a" });
|
||||
};
|
||||
|
||||
const writeGithubSummary = async (summaryLines) => {
|
||||
if (!process.env.GITHUB_STEP_SUMMARY) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(process.env.GITHUB_STEP_SUMMARY, `${summaryLines.join("\n")}\n`, { flag: "a" });
|
||||
};
|
||||
|
||||
const syncSummaryLines = ({
|
||||
changed,
|
||||
repositoryName,
|
||||
githubRelease,
|
||||
releaseAsset,
|
||||
manifest,
|
||||
ipaSha256,
|
||||
}) => [
|
||||
"## Source update summary",
|
||||
"",
|
||||
`- Upstream repository: ${repositoryName}`,
|
||||
`- Selected release: ${githubRelease.tag_name ?? githubRelease.name ?? "unknown"}`,
|
||||
`- Selected asset: ${releaseAsset.name}`,
|
||||
`- Detected app version: ${manifest.version}`,
|
||||
`- Bundle identifier: ${manifest.bundleIdentifier}`,
|
||||
`- File size: ${manifest.size}`,
|
||||
`- SHA-256: ${ipaSha256}`,
|
||||
`- IPA changed: ${changed ? "yes" : "no"}`,
|
||||
"",
|
||||
];
|
||||
|
||||
const runNodeScript = async (scriptPath, scriptArguments, extraEnvironment = {}) => {
|
||||
await execFileAsync(process.execPath, [scriptPath, ...scriptArguments], {
|
||||
cwd: repositoryRoot,
|
||||
@@ -219,6 +306,8 @@ const validateCandidate = async (temporaryDirectory, candidateIpaDirectory, sync
|
||||
candidateSourcePath,
|
||||
"--checksums",
|
||||
candidateChecksumsPath,
|
||||
"--ipa-dir",
|
||||
candidateIpaDirectory,
|
||||
"--skip-offline-fallback",
|
||||
"--skip-legacy-purge",
|
||||
]);
|
||||
@@ -253,6 +342,7 @@ const syncLatestIpa = async () => {
|
||||
const candidateIpaPath = join(temporaryDirectory, "ipas", assetName);
|
||||
const versionLabel = githubRelease.tag_name ?? githubRelease.name ?? assetName;
|
||||
const ipaSha256 = await downloadAssetToFile(releaseAsset, candidateIpaPath);
|
||||
const candidateManifest = await ipaFileManifest(candidateIpaPath, syncOptions);
|
||||
|
||||
if (await checksumExists(syncOptions, ipaSha256)) {
|
||||
await writeGithubOutput({
|
||||
@@ -261,7 +351,22 @@ const syncLatestIpa = async () => {
|
||||
version: versionLabel,
|
||||
sha256: ipaSha256,
|
||||
});
|
||||
console.log(`No update: ${assetName} is already published (${ipaSha256}).`);
|
||||
await writeGithubSummary(syncSummaryLines({
|
||||
changed: false,
|
||||
repositoryName,
|
||||
githubRelease,
|
||||
releaseAsset,
|
||||
manifest: candidateManifest,
|
||||
ipaSha256,
|
||||
}));
|
||||
console.log([
|
||||
`No update: ${assetName} is already published.`,
|
||||
`Selected release: ${versionLabel}`,
|
||||
`Detected app version: ${candidateManifest.version}`,
|
||||
`Bundle identifier: ${candidateManifest.bundleIdentifier}`,
|
||||
`File size: ${candidateManifest.size}`,
|
||||
`SHA-256: ${ipaSha256}`,
|
||||
].join("\n"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -274,8 +379,24 @@ const syncLatestIpa = async () => {
|
||||
version: versionLabel,
|
||||
sha256: ipaSha256,
|
||||
});
|
||||
await writeGithubSummary(syncSummaryLines({
|
||||
changed: true,
|
||||
repositoryName,
|
||||
githubRelease,
|
||||
releaseAsset,
|
||||
manifest: candidateManifest,
|
||||
ipaSha256,
|
||||
}));
|
||||
|
||||
console.log(`Published ${publishedIpaPath} from ${repositoryName} (${ipaSha256}).`);
|
||||
console.log([
|
||||
`Published ${publishedIpaPath} from ${repositoryName}.`,
|
||||
`Selected release: ${versionLabel}`,
|
||||
`Selected asset: ${releaseAsset.name}`,
|
||||
`Detected app version: ${candidateManifest.version}`,
|
||||
`Bundle identifier: ${candidateManifest.bundleIdentifier}`,
|
||||
`File size: ${candidateManifest.size}`,
|
||||
`SHA-256: ${ipaSha256}`,
|
||||
].join("\n"));
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
+251
-12
@@ -4,11 +4,18 @@ import Ajv from "ajv";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { extname, join, relative, resolve, sep } from "node:path";
|
||||
import { basename, extname, join, relative, resolve, sep } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { parseOptions, setOptionFlag, setOptionValue } from "./cli.js";
|
||||
import { bundleIdentifier, repositoryRoot, sourceIdentifier } from "./constants.js";
|
||||
import {
|
||||
bundleIdentifier,
|
||||
canonicalBaseUrl,
|
||||
canonicalSourceUrl,
|
||||
repositoryRoot,
|
||||
sourceIdentifier,
|
||||
} from "./constants.js";
|
||||
import { ipaFileManifest } from "./ipa-metadata.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const schemaUrl = "https://raw.githubusercontent.com/SideStore/sidestore-source-types/main/schema.json";
|
||||
@@ -17,6 +24,7 @@ const generatorPath = resolve(repositoryRoot, "scripts/generate-source.js");
|
||||
const defaults = {
|
||||
sourcePath: "apps.json",
|
||||
checksumPath: "checksums.json",
|
||||
ipaDirectory: "ipas",
|
||||
offlineFallback: true,
|
||||
legacyPurge: true,
|
||||
};
|
||||
@@ -41,6 +49,7 @@ const parseArguments = (cliArguments) => parseOptions(
|
||||
{
|
||||
"--source": setOptionValue("sourcePath"),
|
||||
"--checksums": setOptionValue("checksumPath"),
|
||||
"--ipa-dir": setOptionValue("ipaDirectory"),
|
||||
"--skip-offline-fallback": setOptionFlag("offlineFallback", false),
|
||||
"--skip-legacy-purge": setOptionFlag("legacyPurge", false),
|
||||
},
|
||||
@@ -52,6 +61,88 @@ const jsonDocument = async (jsonPath) => {
|
||||
return JSON.parse(jsonText);
|
||||
};
|
||||
|
||||
const fileBuffer = async (filePath) => readFile(filePath);
|
||||
|
||||
const relativePath = (entryPath) => relative(repositoryRoot, entryPath).split(sep).join("/");
|
||||
|
||||
const filesMatch = async (leftPath, rightPath) => {
|
||||
try {
|
||||
const [leftBuffer, rightBuffer] = await Promise.all([
|
||||
fileBuffer(leftPath),
|
||||
fileBuffer(rightPath),
|
||||
]);
|
||||
|
||||
return leftBuffer.equals(rightBuffer);
|
||||
} catch (filesystemError) {
|
||||
if (filesystemError?.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw filesystemError;
|
||||
}
|
||||
};
|
||||
|
||||
const assertFileExists = async (filePath, label, errors) => {
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
|
||||
if (!fileStats.isFile()) {
|
||||
errors.push(`${label} must be a file.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileStats;
|
||||
} catch (filesystemError) {
|
||||
if (filesystemError?.code === "ENOENT") {
|
||||
errors.push(`${label} is missing.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
throw filesystemError;
|
||||
}
|
||||
};
|
||||
|
||||
const canonicalOrigin = new URL(canonicalBaseUrl).origin;
|
||||
const legacyHost = "j1coding.github.io";
|
||||
const legacyPathPrefix = "/Armsx2-Repo";
|
||||
|
||||
const parsedUrl = (urlValue) => {
|
||||
try {
|
||||
return new URL(urlValue);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isCanonicalPublicUrl = (urlValue) => {
|
||||
const url = parsedUrl(urlValue);
|
||||
return Boolean(url && url.origin === canonicalOrigin);
|
||||
};
|
||||
|
||||
const containsLegacySourceUrl = (payload) => {
|
||||
const payloadText = JSON.stringify(payload);
|
||||
return payloadText.includes(legacyHost) && payloadText.includes(legacyPathPrefix);
|
||||
};
|
||||
|
||||
const urlPathToRepositoryPath = (urlValue) => {
|
||||
const url = parsedUrl(urlValue);
|
||||
|
||||
if (!url || url.origin !== canonicalOrigin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathSegments = url.pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((pathSegment) => decodeURIComponent(pathSegment));
|
||||
|
||||
if (pathSegments.some((pathSegment) => pathSegment === "." || pathSegment === "..")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolve(repositoryRoot, ...pathSegments);
|
||||
};
|
||||
|
||||
const sideStoreSchema = async () => {
|
||||
const schemaResponse = await fetch(schemaUrl, {
|
||||
headers: {
|
||||
@@ -106,6 +197,14 @@ const validateStrictSourceShape = (sourceJson) => {
|
||||
errors.push(`apps.json identifier must be ${sourceIdentifier}.`);
|
||||
}
|
||||
|
||||
if (sourceJson.sourceURL !== canonicalSourceUrl) {
|
||||
errors.push(`apps.json sourceURL must be ${canonicalSourceUrl}.`);
|
||||
}
|
||||
|
||||
if (containsLegacySourceUrl(sourceJson)) {
|
||||
errors.push("apps.json must not contain the old GitHub Pages source URL.");
|
||||
}
|
||||
|
||||
const forbiddenSourceKeys = ["buildVersion", "sha256", "appPermissions", "marketplaceID"];
|
||||
|
||||
for (const forbiddenKey of forbiddenSourceKeys) {
|
||||
@@ -121,20 +220,34 @@ const validateStrictSourceShape = (sourceJson) => {
|
||||
errors.push(`apps[${appIndex}].bundleIdentifier must be ${bundleIdentifier}.`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(sourceApp.screenshotURLs) || sourceApp.screenshotURLs.length === 0) {
|
||||
errors.push(`apps[${appIndex}].screenshotURLs must contain at least one screenshot URL.`);
|
||||
continue;
|
||||
if (!isCanonicalPublicUrl(sourceApp.iconURL)) {
|
||||
errors.push(`apps[${appIndex}].iconURL must use ${canonicalBaseUrl}.`);
|
||||
}
|
||||
|
||||
for (const [screenshotIndex, screenshotURL] of sourceApp.screenshotURLs.entries()) {
|
||||
try {
|
||||
const parsedScreenshotURL = new URL(screenshotURL);
|
||||
if (!Array.isArray(sourceApp.screenshotURLs) || sourceApp.screenshotURLs.length === 0) {
|
||||
errors.push(`apps[${appIndex}].screenshotURLs must contain at least one screenshot URL.`);
|
||||
} else {
|
||||
for (const [screenshotIndex, screenshotURL] of sourceApp.screenshotURLs.entries()) {
|
||||
const parsedScreenshotURL = parsedUrl(screenshotURL);
|
||||
|
||||
if (!parsedScreenshotURL) {
|
||||
errors.push(`apps[${appIndex}].screenshotURLs[${screenshotIndex}] must be an absolute URL.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedScreenshotURL.protocol !== "https:") {
|
||||
errors.push(`apps[${appIndex}].screenshotURLs[${screenshotIndex}] must use HTTPS.`);
|
||||
}
|
||||
} catch {
|
||||
errors.push(`apps[${appIndex}].screenshotURLs[${screenshotIndex}] must be an absolute URL.`);
|
||||
|
||||
if (parsedScreenshotURL.origin !== canonicalOrigin) {
|
||||
errors.push(`apps[${appIndex}].screenshotURLs[${screenshotIndex}] must use ${canonicalBaseUrl}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [versionIndex, sourceVersion] of sourceApp.versions?.entries?.() ?? []) {
|
||||
if (!isCanonicalPublicUrl(sourceVersion.downloadURL)) {
|
||||
errors.push(`apps[${appIndex}].versions[${versionIndex}].downloadURL must use ${canonicalBaseUrl}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +266,14 @@ const validateChecksumManifest = (sourceJson, checksumJson) => {
|
||||
errors.push("checksums.json sourceURL must match apps.json sourceURL.");
|
||||
}
|
||||
|
||||
if (checksumJson.sourceURL !== canonicalSourceUrl) {
|
||||
errors.push(`checksums.json sourceURL must be ${canonicalSourceUrl}.`);
|
||||
}
|
||||
|
||||
if (containsLegacySourceUrl(checksumJson)) {
|
||||
errors.push("checksums.json must not contain the old GitHub Pages source URL.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(checksumJson.files)) {
|
||||
errors.push("checksums.json files must be an array.");
|
||||
return errors;
|
||||
@@ -186,6 +307,121 @@ const validateChecksumManifest = (sourceJson, checksumJson) => {
|
||||
if (!sourceDownloadURLs.has(checksumEntry.downloadURL)) {
|
||||
errors.push(`${checksumPath}.downloadURL is absent from public/apps.json.`);
|
||||
}
|
||||
|
||||
if (!isCanonicalPublicUrl(checksumEntry.downloadURL)) {
|
||||
errors.push(`${checksumPath}.downloadURL must use ${canonicalBaseUrl}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const matchingSourceVersions = (sourceJson) =>
|
||||
(sourceJson.apps ?? []).flatMap((sourceApp) =>
|
||||
(sourceApp.versions ?? []).map((sourceVersion) => ({
|
||||
app: sourceApp,
|
||||
version: sourceVersion,
|
||||
})),
|
||||
);
|
||||
|
||||
const validateGeneratedMirrors = async (validationOptions) => {
|
||||
const errors = [];
|
||||
|
||||
if (validationOptions.sourcePath === defaults.sourcePath) {
|
||||
const rootSourcePath = resolve(repositoryRoot, validationOptions.sourcePath);
|
||||
const publicSourcePath = resolve(repositoryRoot, "public/apps.json");
|
||||
|
||||
if (!await filesMatch(rootSourcePath, publicSourcePath)) {
|
||||
errors.push("public/apps.json must match apps.json.");
|
||||
}
|
||||
}
|
||||
|
||||
if (validationOptions.checksumPath === defaults.checksumPath) {
|
||||
const rootChecksumsPath = resolve(repositoryRoot, validationOptions.checksumPath);
|
||||
const publicChecksumsPath = resolve(repositoryRoot, "public/checksums.json");
|
||||
|
||||
if (!await filesMatch(rootChecksumsPath, publicChecksumsPath)) {
|
||||
errors.push("public/checksums.json must match checksums.json.");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const validateLocalAssets = async (sourceJson) => {
|
||||
const errors = [];
|
||||
const assetUrls = (sourceJson.apps ?? []).flatMap((sourceApp) => [
|
||||
sourceApp.iconURL,
|
||||
...(sourceApp.screenshotURLs ?? []),
|
||||
]).filter(Boolean);
|
||||
|
||||
for (const assetUrl of assetUrls) {
|
||||
const assetPath = urlPathToRepositoryPath(assetUrl);
|
||||
|
||||
if (!assetPath) {
|
||||
errors.push(`${assetUrl} does not map to a local asset path.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const assetLabel = relativePath(assetPath);
|
||||
await assertFileExists(assetPath, assetLabel, errors);
|
||||
|
||||
const publicAssetPath = resolve(repositoryRoot, "public", assetLabel);
|
||||
await assertFileExists(publicAssetPath, `public/${assetLabel}`, errors);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const validateLocalIpas = async (sourceJson, checksumJson, validationOptions) => {
|
||||
const errors = [];
|
||||
const ipaDirectory = resolve(repositoryRoot, validationOptions.ipaDirectory);
|
||||
const sourceVersionsByDownloadURL = new Map(
|
||||
matchingSourceVersions(sourceJson).map(({ version }) => [version.downloadURL, version]),
|
||||
);
|
||||
|
||||
for (const [fileIndex, checksumEntry] of (checksumJson.files ?? []).entries()) {
|
||||
const checksumPath = `files[${fileIndex}]`;
|
||||
const sourceVersion = sourceVersionsByDownloadURL.get(checksumEntry.downloadURL);
|
||||
const ipaPath = resolve(ipaDirectory, basename(checksumEntry.fileName ?? ""));
|
||||
const fileStats = await assertFileExists(ipaPath, `${checksumPath}.fileName local IPA`, errors);
|
||||
|
||||
if (!fileStats) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = await ipaFileManifest(ipaPath, { baseUrl: canonicalBaseUrl });
|
||||
} catch (metadataError) {
|
||||
const message = metadataError instanceof Error ? metadataError.message : String(metadataError);
|
||||
errors.push(`${checksumPath}.fileName could not be read as a valid IPA: ${message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileStats.size !== checksumEntry.size) {
|
||||
errors.push(`${checksumPath}.size must match ${relativePath(ipaPath)} (${fileStats.size}).`);
|
||||
}
|
||||
|
||||
if (manifest.sha256 !== checksumEntry.sha256) {
|
||||
errors.push(`${checksumPath}.sha256 must match ${relativePath(ipaPath)}.`);
|
||||
}
|
||||
|
||||
if (manifest.version !== checksumEntry.version) {
|
||||
errors.push(`${checksumPath}.version must match the local IPA version ${manifest.version}.`);
|
||||
}
|
||||
|
||||
if (manifest.buildVersion !== checksumEntry.buildVersion) {
|
||||
errors.push(`${checksumPath}.buildVersion must match the local IPA build ${manifest.buildVersion}.`);
|
||||
}
|
||||
|
||||
if (sourceVersion && sourceVersion.version !== manifest.version) {
|
||||
errors.push(`${checksumPath}.version must match apps.json version ${sourceVersion.version}.`);
|
||||
}
|
||||
|
||||
if (sourceVersion && sourceVersion.size !== fileStats.size) {
|
||||
errors.push(`${checksumPath}.size must match apps.json size ${sourceVersion.size}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
@@ -220,7 +456,7 @@ const legacyScanRoots = [
|
||||
"public/checksums.json",
|
||||
];
|
||||
|
||||
const relativeRepositoryPath = (entryPath) => relative(repositoryRoot, entryPath).split(sep).join("/");
|
||||
const relativeRepositoryPath = relativePath;
|
||||
|
||||
const isTextFile = (entryPath, entryStats) =>
|
||||
entryStats.size <= 1024 * 1024
|
||||
@@ -349,6 +585,9 @@ const runValidation = async () => {
|
||||
...await validateAgainstSideStoreSchema(sourceJson),
|
||||
...validateStrictSourceShape(sourceJson),
|
||||
...validateChecksumManifest(sourceJson, checksumJson),
|
||||
...await validateGeneratedMirrors(validationOptions),
|
||||
...await validateLocalAssets(sourceJson),
|
||||
...await validateLocalIpas(sourceJson, checksumJson, validationOptions),
|
||||
...(validationOptions.offlineFallback ? await validateOfflineFallback() : []),
|
||||
...(validationOptions.legacyPurge ? await validateLegacyPurge() : []),
|
||||
];
|
||||
@@ -357,7 +596,7 @@ const runValidation = async () => {
|
||||
throw new SourceValidationError(errors);
|
||||
}
|
||||
|
||||
console.log("apps.json validates against the current SideStore schema.");
|
||||
console.log("apps.json and checksums.json validate against source, asset, and IPA checks.");
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user