Fix ARMSX2 iOS source generation reliability

This commit is contained in:
Jeen
2026-06-05 23:42:56 +02:00
parent 43ae01ad94
commit bff70dea43
15 changed files with 531 additions and 69 deletions
+14
View File
@@ -0,0 +1,14 @@
*.js text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.md text eol=lf
*.html text eol=lf
*.css text eol=lf
*.txt text eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ipa binary
+4 -4
View File
@@ -2,9 +2,9 @@
Custom source repository for the ARMSX2 iOS port. Add it to your sideloading app to install and update ARMSX2 on iOS.
[![Install with LiveContainer](https://img.shields.io/badge/Install-LiveContainer-5A1AE5?style=for-the-badge)](livecontainer://source?url=https%3A%2F%2Fj1coding.github.io%2FArmsx2-Repo%2Fapps.json)
[![Add to SideStore](https://img.shields.io/badge/Add-SideStore-9670E9?style=for-the-badge)](sidestore://source?url=https%3A%2F%2Fj1coding.github.io%2FArmsx2-Repo%2Fapps.json)
[![Add to Feather](https://img.shields.io/badge/Add-Feather-1A50E5?style=for-the-badge)](feather://source/https%3A%2F%2Fj1coding.github.io%2FArmsx2-Repo%2Fapps.json)
[![Install with LiveContainer](https://img.shields.io/badge/Install-LiveContainer-5A1AE5?style=for-the-badge)](livecontainer://source?url=https%3A%2F%2Fios.armsx2.net%2Fapps.json)
[![Add to SideStore](https://img.shields.io/badge/Add-SideStore-9670E9?style=for-the-badge)](sidestore://source?url=https%3A%2F%2Fios.armsx2.net%2Fapps.json)
[![Add to Feather](https://img.shields.io/badge/Add-Feather-1A50E5?style=for-the-badge)](feather://source/https%3A%2F%2Fios.armsx2.net%2Fapps.json)
## Requirements
@@ -14,5 +14,5 @@ Custom source repository for the ARMSX2 iOS port. Add it to your sideloading app
## Manual Source URL
```text
https://j1coding.github.io/Armsx2-Repo/apps.json
https://ios.armsx2.net/apps.json
```
+9 -9
View File
@@ -1,7 +1,7 @@
{
"name": "ARMSX2 iOS",
"identifier": "com.j1coding.armsx2.ios",
"sourceURL": "https://j1coding.github.io/Armsx2-Repo/apps.json",
"sourceURL": "https://ios.armsx2.net/apps.json",
"apps": [
{
"name": "ARMSX2 iOS",
@@ -9,20 +9,20 @@
"developerName": "ARMSX2",
"subtitle": "Modern PlayStation 2 emulation for iOS.",
"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.\n\nFeatures:\n- Customizable touchscreen controls and external controller support\n- Graphics settings, performance options, save states, and memory card support\n- Local-network support for compatible online titles\n\nImportant iOS requirements:\nA JIT-capable setup is required for playable performance. Compatibility varies by device, iOS version, game, and sideloading environment, including LiveContainer, SideStore, or Feather.\n\nDisclaimer:\nARMSX2 does not include games or BIOS files. Users must provide their own legally obtained BIOS and game backups, such as ISO files. ARMSX2 is not affiliated with Sony Interactive Entertainment or PlayStation.\n\nCredits:\nThe upstream ARMSX2 project was founded by jpolo1224 and MoonPower, with work from contributors including Zeruth and Dib1998.",
"iconURL": "https://j1coding.github.io/Armsx2-Repo/assets/icon.png",
"iconURL": "https://ios.armsx2.net/assets/icon.png",
"screenshotURLs": [
"https://j1coding.github.io/Armsx2-Repo/assets/screenshots/01-library.png",
"https://j1coding.github.io/Armsx2-Repo/assets/screenshots/02-settings.png",
"https://j1coding.github.io/Armsx2-Repo/assets/screenshots/03-gameplay.png"
"https://ios.armsx2.net/assets/screenshots/01-library.png",
"https://ios.armsx2.net/assets/screenshots/02-settings.png",
"https://ios.armsx2.net/assets/screenshots/03-gameplay.png"
],
"tintColor": "#2F6FAD",
"versions": [
{
"version": "1.3",
"version": "1.3.1",
"date": "2026-06-05",
"localizedDescription": "Updated to ARMSX2 iOS 1.3.\n\nAdded the Burnout graphics skyfix path present in 1.2.4 to Burnout 3: Takedown.\n\nImproved EE performance behavior by removing old iPSX2 diagnostic overhead from hot emulation paths. Overall emulator performance is now improved, and device heat may be improved as well.\n\nVarious games have received corrected graphics thanks to @Dib1998 VU fix in 1.2.4\n\nFixed custom PNG texture replacements appearing too dark on iOS thanks to @yuliangtao\n\nUpdated app and OSD version display to ARMSX2 iOS 1.3.",
"downloadURL": "https://j1coding.github.io/Armsx2-Repo/ipas/ARMSX2-iOS-1.3.ipa",
"size": 14107294,
"localizedDescription": "Updated to ARMSX2 iOS 1.3.1.\n\nFixed Blacks black sky on iOS Metal.\n\nUpdated app metadata, OSD, and build marker to 1.3.1.\n\nMTVU and deinterlacing are now available in Per-Game Settings.\n\nThe Virtual Pad can now automatically hide when an external controller is connected.\n\nSilent Hill 3 graphics issues are also fixed by disabling gamedb core fixes.",
"downloadURL": "https://ios.armsx2.net/ipas/ARMSX2-iOS-1.3.1.ipa",
"size": 14116766,
"minOSVersion": "18.0"
}
],
+8 -8
View File
@@ -1,17 +1,17 @@
{
"sourceIdentifier": "com.j1coding.armsx2.ios",
"sourceURL": "https://j1coding.github.io/Armsx2-Repo/apps.json",
"generatedAt": "2026-06-05T00:44:34.000Z",
"sourceURL": "https://ios.armsx2.net/apps.json",
"generatedAt": "2026-06-05T17:07:56.000Z",
"files": [
{
"fileName": "ARMSX2-iOS-1.3.ipa",
"fileName": "ARMSX2-iOS-1.3.1.ipa",
"bundleIdentifier": "com.armsx2.ios",
"version": "1.3",
"buildVersion": "136",
"version": "1.3.1",
"buildVersion": "137",
"date": "2026-06-05",
"downloadURL": "https://j1coding.github.io/Armsx2-Repo/ipas/ARMSX2-iOS-1.3.ipa",
"size": 14107294,
"sha256": "22718a7005e2648e7ca963deb71cc2b5f926065de4689afe8df9bf4139e8ed6d"
"downloadURL": "https://ios.armsx2.net/ipas/ARMSX2-iOS-1.3.1.ipa",
"size": 14116766,
"sha256": "2dbbd4bba62fa2044bc41d3131c36d33ab2eb8aa8b50f5bda3aaf7abf1532e5f"
}
]
}
+2 -1
View File
@@ -11,7 +11,8 @@
"generate:source": "node scripts/generate-source.js",
"check:source": "node scripts/generate-source.js --check",
"sync:upstream": "node scripts/sync-upstream-ipa.js",
"validate:source": "node scripts/validate-source.js"
"validate:source": "node scripts/validate-source.js",
"smoke:live": "node scripts/smoke-live.js"
},
"dependencies": {
"adm-zip": "^0.5.16",
+9 -9
View File
@@ -1,7 +1,7 @@
{
"name": "ARMSX2 iOS",
"identifier": "com.j1coding.armsx2.ios",
"sourceURL": "https://j1coding.github.io/Armsx2-Repo/apps.json",
"sourceURL": "https://ios.armsx2.net/apps.json",
"apps": [
{
"name": "ARMSX2 iOS",
@@ -9,20 +9,20 @@
"developerName": "ARMSX2",
"subtitle": "Modern PlayStation 2 emulation for iOS.",
"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.\n\nFeatures:\n- Customizable touchscreen controls and external controller support\n- Graphics settings, performance options, save states, and memory card support\n- Local-network support for compatible online titles\n\nImportant iOS requirements:\nA JIT-capable setup is required for playable performance. Compatibility varies by device, iOS version, game, and sideloading environment, including LiveContainer, SideStore, or Feather.\n\nDisclaimer:\nARMSX2 does not include games or BIOS files. Users must provide their own legally obtained BIOS and game backups, such as ISO files. ARMSX2 is not affiliated with Sony Interactive Entertainment or PlayStation.\n\nCredits:\nThe upstream ARMSX2 project was founded by jpolo1224 and MoonPower, with work from contributors including Zeruth and Dib1998.",
"iconURL": "https://j1coding.github.io/Armsx2-Repo/assets/icon.png",
"iconURL": "https://ios.armsx2.net/assets/icon.png",
"screenshotURLs": [
"https://j1coding.github.io/Armsx2-Repo/assets/screenshots/01-library.png",
"https://j1coding.github.io/Armsx2-Repo/assets/screenshots/02-settings.png",
"https://j1coding.github.io/Armsx2-Repo/assets/screenshots/03-gameplay.png"
"https://ios.armsx2.net/assets/screenshots/01-library.png",
"https://ios.armsx2.net/assets/screenshots/02-settings.png",
"https://ios.armsx2.net/assets/screenshots/03-gameplay.png"
],
"tintColor": "#2F6FAD",
"versions": [
{
"version": "1.3",
"version": "1.3.1",
"date": "2026-06-05",
"localizedDescription": "Updated to ARMSX2 iOS 1.3.\n\nAdded the Burnout graphics skyfix path present in 1.2.4 to Burnout 3: Takedown.\n\nImproved EE performance behavior by removing old iPSX2 diagnostic overhead from hot emulation paths. Overall emulator performance is now improved, and device heat may be improved as well.\n\nVarious games have received corrected graphics thanks to @Dib1998 VU fix in 1.2.4\n\nFixed custom PNG texture replacements appearing too dark on iOS thanks to @yuliangtao\n\nUpdated app and OSD version display to ARMSX2 iOS 1.3.",
"downloadURL": "https://j1coding.github.io/Armsx2-Repo/ipas/ARMSX2-iOS-1.3.ipa",
"size": 14107294,
"localizedDescription": "Updated to ARMSX2 iOS 1.3.1.\n\nFixed Blacks black sky on iOS Metal.\n\nUpdated app metadata, OSD, and build marker to 1.3.1.\n\nMTVU and deinterlacing are now available in Per-Game Settings.\n\nThe Virtual Pad can now automatically hide when an external controller is connected.\n\nSilent Hill 3 graphics issues are also fixed by disabling gamedb core fixes.",
"downloadURL": "https://ios.armsx2.net/ipas/ARMSX2-iOS-1.3.1.ipa",
"size": 14116766,
"minOSVersion": "18.0"
}
],
+8 -8
View File
@@ -1,17 +1,17 @@
{
"sourceIdentifier": "com.j1coding.armsx2.ios",
"sourceURL": "https://j1coding.github.io/Armsx2-Repo/apps.json",
"generatedAt": "2026-06-05T00:44:34.000Z",
"sourceURL": "https://ios.armsx2.net/apps.json",
"generatedAt": "2026-06-05T17:07:56.000Z",
"files": [
{
"fileName": "ARMSX2-iOS-1.3.ipa",
"fileName": "ARMSX2-iOS-1.3.1.ipa",
"bundleIdentifier": "com.armsx2.ios",
"version": "1.3",
"buildVersion": "136",
"version": "1.3.1",
"buildVersion": "137",
"date": "2026-06-05",
"downloadURL": "https://j1coding.github.io/Armsx2-Repo/ipas/ARMSX2-iOS-1.3.ipa",
"size": 14107294,
"sha256": "22718a7005e2648e7ca963deb71cc2b5f926065de4689afe8df9bf4139e8ed6d"
"downloadURL": "https://ios.armsx2.net/ipas/ARMSX2-iOS-1.3.1.ipa",
"size": 14116766,
"sha256": "2dbbd4bba62fa2044bc41d3131c36d33ab2eb8aa8b50f5bda3aaf7abf1532e5f"
}
]
}
+3 -1
View File
@@ -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;
+2 -2
View File
@@ -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);
}
}
+1 -1
View File
@@ -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));
};
+83
View File
@@ -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;
}
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {