commit bf515bddc8541ddc1f28c0fb20fcade5d9694e6e Author: Jeen Date: Mon Jun 1 23:28:04 2026 +0200 initial test release diff --git a/.github/workflows/update-source.yml b/.github/workflows/update-source.yml new file mode 100644 index 0000000..7475f7b --- /dev/null +++ b/.github/workflows/update-source.yml @@ -0,0 +1,77 @@ +name: Update Source + +on: + workflow_dispatch: + inputs: + include_prereleases: + description: "Include prerelease upstream releases" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + schedule: + - cron: "17 */6 * * *" + +permissions: + contents: write + +concurrency: + group: update-source + cancel-in-progress: false + +jobs: + update-source: + runs-on: ubuntu-latest + + env: + ARMSX2_PUBLIC_BASE_URL: https://j1coding.github.io/Armsx2-Repo + INCLUDE_PRERELEASES: ${{ inputs.include_prereleases || 'false' }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.SOURCE_UPDATE_TOKEN || secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Sync latest upstream IPA + id: sync + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run sync:upstream + + - name: Regenerate source files + if: steps.sync.outputs.changed == 'true' + run: npm run generate:source + + - name: Validate generated source + if: steps.sync.outputs.changed == 'true' + run: | + npm run check:source + npm run validate:source + + - name: Commit and push update + if: steps.sync.outputs.changed == 'true' + run: | + if git diff --quiet --exit-code; then + echo "No generated changes to commit." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add apps.json checksums.json public/ ipas/ assets/ index.html + git commit -m "Update ARMSX2 iOS source: ${{ steps.sync.outputs.version }}" + git push origin HEAD:${{ github.ref_name }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..2fb1512 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,28 @@ +name: Validate + +on: + push: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check generated files + run: npm run check:source + + - name: Validate source schema + run: npm run validate:source diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cef46e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.vite/ +dist/ +coverage/ +npm-debug.log* diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e78f0c7 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# ARMSX2 iOS + +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) + +## Requirements + +- A JIT-capable setup is required. +- You must provide your own PS2 BIOS and legally dumped ISO files. + +## Manual Source URL + +```text +https://j1coding.github.io/Armsx2-Repo/apps.json +``` diff --git a/apps.json b/apps.json new file mode 100644 index 0000000..2f77296 --- /dev/null +++ b/apps.json @@ -0,0 +1,37 @@ +{ + "name": "ARMSX2 iOS", + "identifier": "com.j1coding.armsx2.ios", + "sourceURL": "https://j1coding.github.io/Armsx2-Repo/apps.json", + "apps": [ + { + "name": "ARMSX2 iOS", + "bundleIdentifier": "com.armsx2.ios", + "developerName": "ARMSX2", + "subtitle": "PlayStation 2 emulation, built for iOS.", + "localizedDescription": "ARMSX2 brings PlayStation 2 emulation to iOS with an ARM64-focused build, controller-friendly play, and local-network support for compatible titles.\n\nA JIT-capable setup is required. Performance and compatibility vary by device, iOS version, game, and sideloading environment. You must provide your own BIOS and game backups.", + "iconURL": "https://j1coding.github.io/Armsx2-Repo/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" + ], + "tintColor": "#2F6FAD", + "versions": [ + { + "version": "1.2.2", + "date": "2026-06-01", + "localizedDescription": "Updated to ARMSX2 iOS 1.2.2.\n\nUpdated ARMSX2 iOS version display to 1.2.2.\nAdded battery percentage to the performance overlay.\nAdded device heat status to the performance overlay.\nAdded app RAM usage to the performance overlay.\nAdded Low Power Mode indicator when enabled.\nAdded color warnings for heat and low battery states.\nAdded new OSD/Overlay settings page.\nAdded OSD preset controls for Off, Simple, Detail, and Full.\nAdded OSD position setting.\nImproved performance overlay readability.", + "downloadURL": "https://j1coding.github.io/Armsx2-Repo/ipas/ARMSX2-iOSv1.2.2.ipa", + "size": 14005979, + "minOSVersion": "18.0" + } + ], + "permissions": [ + { + "type": "network", + "usageDescription": "ARMSX2 uses local network access for PS2 online play through DEV9 sockets." + } + ] + } + ] +} diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..8beed8a Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/screenshots/01-library.png b/assets/screenshots/01-library.png new file mode 100644 index 0000000..d9f6000 Binary files /dev/null and b/assets/screenshots/01-library.png differ diff --git a/assets/screenshots/02-settings.png b/assets/screenshots/02-settings.png new file mode 100644 index 0000000..702a48d Binary files /dev/null and b/assets/screenshots/02-settings.png differ diff --git a/assets/screenshots/03-gameplay.png b/assets/screenshots/03-gameplay.png new file mode 100644 index 0000000..e43cc30 Binary files /dev/null and b/assets/screenshots/03-gameplay.png differ diff --git a/checksums.json b/checksums.json new file mode 100644 index 0000000..06f20a2 --- /dev/null +++ b/checksums.json @@ -0,0 +1,17 @@ +{ + "sourceIdentifier": "com.j1coding.armsx2.ios", + "sourceURL": "https://j1coding.github.io/Armsx2-Repo/apps.json", + "generatedAt": "2026-06-01T00:14:00.000Z", + "files": [ + { + "fileName": "ARMSX2-iOSv1.2.2.ipa", + "bundleIdentifier": "com.armsx2.ios", + "version": "1.2.2", + "buildVersion": "2", + "date": "2026-06-01", + "downloadURL": "https://j1coding.github.io/Armsx2-Repo/ipas/ARMSX2-iOSv1.2.2.ipa", + "size": 14005979, + "sha256": "9bd484af8ae1289693a64d6d83a869fc5bc6acece7369a01a68f697307b2dd47" + } + ] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..3872466 --- /dev/null +++ b/index.html @@ -0,0 +1,1017 @@ + + + + + + + + ARMSX2 iOS + + + + +
+
+ ARMSX2 iOS + Checksums +
+ +
+
+
+
+ +
+

LiveContainer / SideStore / Feather

+

ARMSX2 iOS

+
+
+

+ +

+

+ +

+
+ + +
+ +
+
+
+

Preview

+

Screenshots

+
+
+
+
+ +
+
+

Latest Update

+

What’s New

+

Loading release notes

+
+
+ +
+

Before You Install

+

Requirements

+
    +
  • Minimum iOS version loads from apps.json.
  • +
  • A JIT-capable setup is needed for playable performance.
  • +
  • LiveContainer automatic import requires LiveContainer 3.7.0 or later.
  • +
  • You’ll need your own PS2 BIOS file and dumped game disc images, such as ISO files.
  • +
+
+ +
+

About

+

Description

+
+ +
+
+ Checksum details load from checksums.json. +
+ View checksums.json +
+
+
+
+
+ + + + diff --git a/ipas/ARMSX2-iOSv1.2.2.ipa b/ipas/ARMSX2-iOSv1.2.2.ipa new file mode 100644 index 0000000..b3b94fe Binary files /dev/null and b/ipas/ARMSX2-iOSv1.2.2.ipa differ diff --git a/metadata/store.json b/metadata/store.json new file mode 100644 index 0000000..77e0a97 --- /dev/null +++ b/metadata/store.json @@ -0,0 +1,13 @@ +{ + "app": { + "subtitle": "PlayStation 2 emulation, built for iOS.", + "localizedDescription": "ARMSX2 brings PlayStation 2 emulation to iOS with an ARM64-focused build, controller-friendly play, and local-network support for compatible titles.\n\nA JIT-capable setup is required. Performance and compatibility vary by device, iOS version, game, and sideloading environment. You must provide your own BIOS and game backups." + }, + "screenshots": { + "directory": "assets/screenshots" + }, + "releaseNotes": { + "upstreamRepository": "ARMSX2/ARMSX2", + "fallback": "Updated to ARMSX2 iOS {version}.\n\nIncludes the latest published iOS build, verified download metadata, and local-network permission disclosure for supported online features." + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fddd58b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,163 @@ +{ + "name": "armsx2-ios-source", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "armsx2-ios-source", + "version": "2.0.0", + "dependencies": { + "adm-zip": "^0.5.16", + "bplist-parser": "^0.3.2", + "plist": "^3.1.0" + }, + "devDependencies": { + "ajv": "^8.17.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3cb2c60 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "armsx2-ios-source", + "version": "2.0.0", + "private": true, + "type": "module", + "description": "ARMSX2 iOS source generator for LiveContainer, SideStore, and Feather.", + "engines": { + "node": ">=22" + }, + "scripts": { + "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" + }, + "dependencies": { + "adm-zip": "^0.5.16", + "bplist-parser": "^0.3.2", + "plist": "^3.1.0" + }, + "devDependencies": { + "ajv": "^8.17.1" + } +} diff --git a/public/.nojekyll b/public/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/public/apps.json b/public/apps.json new file mode 100644 index 0000000..2f77296 --- /dev/null +++ b/public/apps.json @@ -0,0 +1,37 @@ +{ + "name": "ARMSX2 iOS", + "identifier": "com.j1coding.armsx2.ios", + "sourceURL": "https://j1coding.github.io/Armsx2-Repo/apps.json", + "apps": [ + { + "name": "ARMSX2 iOS", + "bundleIdentifier": "com.armsx2.ios", + "developerName": "ARMSX2", + "subtitle": "PlayStation 2 emulation, built for iOS.", + "localizedDescription": "ARMSX2 brings PlayStation 2 emulation to iOS with an ARM64-focused build, controller-friendly play, and local-network support for compatible titles.\n\nA JIT-capable setup is required. Performance and compatibility vary by device, iOS version, game, and sideloading environment. You must provide your own BIOS and game backups.", + "iconURL": "https://j1coding.github.io/Armsx2-Repo/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" + ], + "tintColor": "#2F6FAD", + "versions": [ + { + "version": "1.2.2", + "date": "2026-06-01", + "localizedDescription": "Updated to ARMSX2 iOS 1.2.2.\n\nUpdated ARMSX2 iOS version display to 1.2.2.\nAdded battery percentage to the performance overlay.\nAdded device heat status to the performance overlay.\nAdded app RAM usage to the performance overlay.\nAdded Low Power Mode indicator when enabled.\nAdded color warnings for heat and low battery states.\nAdded new OSD/Overlay settings page.\nAdded OSD preset controls for Off, Simple, Detail, and Full.\nAdded OSD position setting.\nImproved performance overlay readability.", + "downloadURL": "https://j1coding.github.io/Armsx2-Repo/ipas/ARMSX2-iOSv1.2.2.ipa", + "size": 14005979, + "minOSVersion": "18.0" + } + ], + "permissions": [ + { + "type": "network", + "usageDescription": "ARMSX2 uses local network access for PS2 online play through DEV9 sockets." + } + ] + } + ] +} diff --git a/public/assets/icon.png b/public/assets/icon.png new file mode 100644 index 0000000..8beed8a Binary files /dev/null and b/public/assets/icon.png differ diff --git a/public/assets/screenshots/01-library.png b/public/assets/screenshots/01-library.png new file mode 100644 index 0000000..d9f6000 Binary files /dev/null and b/public/assets/screenshots/01-library.png differ diff --git a/public/assets/screenshots/02-settings.png b/public/assets/screenshots/02-settings.png new file mode 100644 index 0000000..702a48d Binary files /dev/null and b/public/assets/screenshots/02-settings.png differ diff --git a/public/assets/screenshots/03-gameplay.png b/public/assets/screenshots/03-gameplay.png new file mode 100644 index 0000000..e43cc30 Binary files /dev/null and b/public/assets/screenshots/03-gameplay.png differ diff --git a/public/checksums.json b/public/checksums.json new file mode 100644 index 0000000..06f20a2 --- /dev/null +++ b/public/checksums.json @@ -0,0 +1,17 @@ +{ + "sourceIdentifier": "com.j1coding.armsx2.ios", + "sourceURL": "https://j1coding.github.io/Armsx2-Repo/apps.json", + "generatedAt": "2026-06-01T00:14:00.000Z", + "files": [ + { + "fileName": "ARMSX2-iOSv1.2.2.ipa", + "bundleIdentifier": "com.armsx2.ios", + "version": "1.2.2", + "buildVersion": "2", + "date": "2026-06-01", + "downloadURL": "https://j1coding.github.io/Armsx2-Repo/ipas/ARMSX2-iOSv1.2.2.ipa", + "size": 14005979, + "sha256": "9bd484af8ae1289693a64d6d83a869fc5bc6acece7369a01a68f697307b2dd47" + } + ] +} diff --git a/scripts/cli.js b/scripts/cli.js new file mode 100644 index 0000000..8ca932a --- /dev/null +++ b/scripts/cli.js @@ -0,0 +1,38 @@ +export const setOptionValue = (optionKey) => ({ options, readValue }) => { + options[optionKey] = readValue(); +}; + +export const setOptionFlag = (optionKey, flagValue = true) => ({ options }) => { + options[optionKey] = flagValue; +}; + +export const parseOptions = (cliArguments, defaults, handlers, createError = (message) => new Error(message)) => { + const argumentQueue = [...cliArguments]; + const options = { ...defaults }; + + const readValue = (optionName) => { + const optionValue = argumentQueue.shift(); + + if (!optionValue || optionValue.startsWith("--")) { + throw createError(`${optionName} requires a value.`); + } + + return optionValue; + }; + + while (argumentQueue.length > 0) { + const optionName = argumentQueue.shift(); + const optionHandler = handlers[optionName]; + + if (!optionHandler) { + throw createError(`Unknown option: ${optionName}`); + } + + optionHandler({ + options, + readValue: () => readValue(optionName), + }); + } + + return options; +}; diff --git a/scripts/constants.js b/scripts/constants.js new file mode 100644 index 0000000..0f77ce8 --- /dev/null +++ b/scripts/constants.js @@ -0,0 +1,7 @@ +import { dirname, resolve } from "node:path"; +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"; diff --git a/scripts/errors.js b/scripts/errors.js new file mode 100644 index 0000000..2db141f --- /dev/null +++ b/scripts/errors.js @@ -0,0 +1,6 @@ +export class SourceGenerationError extends Error { + constructor(message) { + super(message); + this.name = "SourceGenerationError"; + } +} diff --git a/scripts/generate-source.js b/scripts/generate-source.js new file mode 100644 index 0000000..a27aa32 --- /dev/null +++ b/scripts/generate-source.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, relative, resolve, sep } from "node:path"; + +import { parseOptions, setOptionFlag, setOptionValue } from "./cli.js"; +import { defaultBaseUrl, repositoryRoot } from "./constants.js"; +import { SourceGenerationError } from "./errors.js"; +import { generatedBuffers, mirrorPublicAsset } from "./source-builder.js"; + +const defaults = { + inputDirectory: "ipas", + sourcePath: "apps.json", + checksumPath: "checksums.json", + metadataPath: "metadata/store.json", + screenshotDirectory: null, + upstreamReleaseRepo: process.env.ARMSX2_UPSTREAM_RELEASE_REPO || null, + baseUrl: process.env.ARMSX2_PUBLIC_BASE_URL || defaultBaseUrl, + check: false, + requireIpa: false, + offline: process.env.ARMSX2_OFFLINE === "1", +}; + +const parseArguments = (cliArguments) => parseOptions( + cliArguments, + defaults, + { + "--input-dir": setOptionValue("inputDirectory"), + "--output": setOptionValue("sourcePath"), + "--checksums": setOptionValue("checksumPath"), + "--base-url": setOptionValue("baseUrl"), + "--metadata": setOptionValue("metadataPath"), + "--screenshots": setOptionValue("screenshotDirectory"), + "--upstream-release-repo": setOptionValue("upstreamReleaseRepo"), + "--check": setOptionFlag("check"), + "--require-ipa": setOptionFlag("requireIpa"), + "--offline": setOptionFlag("offline"), + }, + (message) => new SourceGenerationError(message), +); + +const writeGeneratedFile = async (outputPath, generatedBuffer) => { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, generatedBuffer); +}; + +const checkedFileMatches = async (outputPath, generatedBuffer) => { + try { + const checkedInBuffer = await readFile(outputPath); + return checkedInBuffer.equals(generatedBuffer); + } catch (filesystemError) { + if (filesystemError?.code === "ENOENT") { + return false; + } + + throw filesystemError; + } +}; + +const defaultOutputPaths = (generatorOptions) => + generatorOptions.sourcePath === defaults.sourcePath + && generatorOptions.checksumPath === defaults.checksumPath; + +const outputTargets = (generatorOptions) => { + const sourceOutputPath = resolve(repositoryRoot, generatorOptions.sourcePath); + const checksumOutputPath = resolve(repositoryRoot, generatorOptions.checksumPath); + const targets = [ + { label: "source", path: sourceOutputPath }, + { label: "checksums", path: checksumOutputPath }, + ]; + + if (!defaultOutputPaths(generatorOptions)) { + return targets; + } + + return [ + ...targets, + { label: "source", path: resolve(repositoryRoot, "public/apps.json") }, + { label: "checksums", path: resolve(repositoryRoot, "public/checksums.json") }, + ]; +}; + +const assertGeneratedFilesCurrent = async (targets, generated) => { + const targetStatuses = await Promise.all( + targets.map(async (outputTarget) => ({ + ...outputTarget, + current: await checkedFileMatches(outputTarget.path, generated[outputTarget.label]), + })), + ); + + if (targetStatuses.every((targetStatus) => targetStatus.current)) { + console.log(`Source files are current for ${generated.ipaCount} IPA file${generated.ipaCount === 1 ? "" : "s"}.`); + return; + } + + throw new SourceGenerationError("Generated source files are stale. Run npm run generate:source."); +}; + +const writeGeneratedTargets = async (targets, generated) => { + for (const outputTarget of targets) { + await writeGeneratedFile(outputTarget.path, generated[outputTarget.label]); + console.log(`Generated ${relative(repositoryRoot, outputTarget.path).split(sep).join("/")}.`); + } +}; + +const runGenerator = async () => { + const generatorOptions = parseArguments(process.argv.slice(2)); + const generated = await generatedBuffers(generatorOptions); + const targets = outputTargets(generatorOptions); + + if (generatorOptions.check) { + await assertGeneratedFilesCurrent(targets, generated); + return; + } + + await writeGeneratedTargets(targets, generated); + + if (defaultOutputPaths(generatorOptions)) { + for (const screenshotFile of generated.screenshotFiles) { + await mirrorPublicAsset(screenshotFile); + } + } + + console.log(`Indexed ${generated.ipaCount} IPA file${generated.ipaCount === 1 ? "" : "s"}.`); +}; + +try { + await runGenerator(); +} catch (generationError) { + console.error(generationError instanceof Error ? generationError.message : generationError); + process.exitCode = 1; +} diff --git a/scripts/github-releases.js b/scripts/github-releases.js new file mode 100644 index 0000000..8f81f67 --- /dev/null +++ b/scripts/github-releases.js @@ -0,0 +1,135 @@ +import { resolve } from "node:path"; + +import { SourceGenerationError } from "./errors.js"; +import { optionalJsonDocument } from "./source-utils.js"; + +const releaseRepository = (generatorOptions, metadataPayload) => + generatorOptions.upstreamReleaseRepo + ?? metadataPayload.releaseNotes.upstreamRepository + ?? null; + +const validReleaseRepository = (repositoryName) => + typeof repositoryName === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repositoryName); + +const githubHeaders = () => { + const headers = { + Accept: "application/vnd.github+json", + "User-Agent": "J1coding-ARMSX2-Source-Generator/2.1", + }; + + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + + return headers; +}; + +export const fetchGithubReleases = async (generatorOptions, metadataPayload) => { + const repositoryName = releaseRepository(generatorOptions, metadataPayload); + + if (generatorOptions.offline || !repositoryName) { + return []; + } + + if (!validReleaseRepository(repositoryName)) { + throw new SourceGenerationError(`Invalid upstream release repository: ${repositoryName}`); + } + + try { + const releaseResponse = await fetch(`https://api.github.com/repos/${repositoryName}/releases?per_page=100`, { + headers: githubHeaders(), + }); + + if (!releaseResponse.ok) { + console.warn( + `GitHub release lookup for ${repositoryName} failed: ${releaseResponse.status} ${releaseResponse.statusText}`, + ); + return []; + } + + const releasePayload = await releaseResponse.json(); + return Array.isArray(releasePayload) ? releasePayload : []; + } catch (fetchError) { + const message = fetchError instanceof Error ? fetchError.message : String(fetchError); + console.warn(`GitHub release lookup for ${repositoryName} failed: ${message}`); + return []; + } +}; + +const escapedRegExp = (patternText) => patternText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + +const releaseMatchesManifest = (githubRelease, manifest) => { + const releaseFields = [ + githubRelease.tag_name, + githubRelease.name, + ].filter(Boolean).map(String); + + const versionPattern = new RegExp(`(^|[^0-9])v?${escapedRegExp(manifest.version)}([^0-9]|$)`, "iu"); + return releaseFields.some((releaseField) => versionPattern.test(releaseField)); +}; + +const matchingGithubRelease = (githubReleases, manifest) => + githubReleases.find((githubRelease) => releaseMatchesManifest(githubRelease, manifest)) ?? null; + +const markdownToStoreText = (markdownText) => { + const cleanedText = String(markdownText) + .replace(/\r\n?/gu, "\n") + .replace(/```[\s\S]*?```/gu, "") + .replace(/!\[[^\]]*\]\([^)]*\)/gu, "") + .replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1") + .replace(/`([^`]+)`/gu, "$1") + .replace(/^#{1,6}\s*/gmu, "") + .replace(/^\s*[-*+]\s+/gmu, "- ") + .replace(/\n{3,}/gu, "\n\n") + .trim(); + + return cleanedText + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => !/^full changelog:?/iu.test(line)) + .slice(0, 10) + .join("\n") + .slice(0, 1200) + .trim(); +}; + +const fallbackChangelog = (manifest, metadataPayload) => { + const fallbackTemplate = metadataPayload.releaseNotes.fallback + ?? "Updated to ARMSX2 iOS {version}.\n\nIncludes the latest published iOS build, verified download metadata, and local-network permission disclosure for supported online features."; + + return fallbackTemplate.replaceAll("{version}", manifest.version); +}; + +export const existingVersionDescriptions = async (repositoryRoot, sourcePath) => { + const existingSourcePath = resolve(repositoryRoot, sourcePath); + const existingSourcePayload = await optionalJsonDocument(existingSourcePath); + const versionDescriptions = new Map(); + + for (const sourceApp of existingSourcePayload.apps ?? []) { + for (const sourceVersion of sourceApp.versions ?? []) { + const description = sourceVersion.localizedDescription; + + if (typeof description !== "string" || !description.startsWith("Updated to ARMSX2 iOS")) { + continue; + } + + versionDescriptions.set(`${sourceVersion.version}|${sourceVersion.downloadURL}`, description); + versionDescriptions.set(sourceVersion.version, description); + } + } + + return versionDescriptions; +}; + +export const storeChangelog = (manifest, metadataPayload, githubReleases, existingDescriptions) => { + const githubRelease = matchingGithubRelease(githubReleases, manifest); + const releaseBody = markdownToStoreText(githubRelease?.body ?? ""); + + if (releaseBody) { + return `Updated to ARMSX2 iOS ${manifest.version}.\n\n${releaseBody}`; + } + + return existingDescriptions.get(`${manifest.version}|${manifest.downloadURL}`) + ?? existingDescriptions.get(manifest.version) + ?? fallbackChangelog(manifest, metadataPayload); +}; diff --git a/scripts/ipa-metadata.js b/scripts/ipa-metadata.js new file mode 100644 index 0000000..039057a --- /dev/null +++ b/scripts/ipa-metadata.js @@ -0,0 +1,316 @@ +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), + }; +}; diff --git a/scripts/source-builder.js b/scripts/source-builder.js new file mode 100644 index 0000000..9be27ea --- /dev/null +++ b/scripts/source-builder.js @@ -0,0 +1,170 @@ +import { copyFile, mkdir, stat } from "node:fs/promises"; +import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; + +import { bundleIdentifier, repositoryRoot, sourceIdentifier } from "./constants.js"; +import { SourceGenerationError } from "./errors.js"; +import { + existingVersionDescriptions, + fetchGithubReleases, + storeChangelog, +} from "./github-releases.js"; +import { compareIpaManifests, ipaFileManifest } from "./ipa-metadata.js"; +import { + jsonBuffer, + optionalJsonDocument, + publicAssetUrl, + readDirectoryEntries, +} from "./source-utils.js"; + +const findIpaFiles = async (inputDirectory) => { + const directoryEntries = await readDirectoryEntries(inputDirectory); + + return directoryEntries + .filter((directoryEntry) => directoryEntry.isFile()) + .filter((directoryEntry) => extname(directoryEntry.name).toLowerCase() === ".ipa") + .map((directoryEntry) => join(inputDirectory, directoryEntry.name)) + .sort((leftPath, rightPath) => basename(leftPath).localeCompare(basename(rightPath))); +}; + +const storeMetadata = async (generatorOptions) => { + const metadataPath = resolve(repositoryRoot, generatorOptions.metadataPath); + const metadataPayload = await optionalJsonDocument(metadataPath); + + return { + app: metadataPayload.app ?? {}, + screenshots: metadataPayload.screenshots ?? {}, + releaseNotes: metadataPayload.releaseNotes ?? {}, + }; +}; + +const imageExtensions = new Set([".png", ".jpg", ".jpeg", ".webp"]); + +const screenshotDirectory = (generatorOptions, metadataPayload) => + generatorOptions.screenshotDirectory + ?? metadataPayload.screenshots.directory + ?? "assets/screenshots"; + +const findScreenshotFiles = async (generatorOptions, metadataPayload) => { + const screenshotRoot = resolve(repositoryRoot, screenshotDirectory(generatorOptions, metadataPayload)); + const screenshotEntries = await readDirectoryEntries(screenshotRoot); + + return screenshotEntries + .filter((directoryEntry) => directoryEntry.isFile()) + .filter((directoryEntry) => imageExtensions.has(extname(directoryEntry.name).toLowerCase())) + .map((directoryEntry) => relative(repositoryRoot, join(screenshotRoot, directoryEntry.name)).split(sep).join("/")) + .sort((leftPath, rightPath) => leftPath.localeCompare(rightPath, undefined, { numeric: true })); +}; + +export const mirrorPublicAsset = async (publicRelativePath) => { + const sourcePath = resolve(repositoryRoot, publicRelativePath); + const mirrorPath = resolve(repositoryRoot, "public", publicRelativePath); + + try { + await stat(sourcePath); + } catch (filesystemError) { + if (filesystemError?.code === "ENOENT") { + return; + } + + throw filesystemError; + } + + await mkdir(dirname(mirrorPath), { recursive: true }); + await copyFile(sourcePath, mirrorPath); +}; + +const compactObject = (record) => + Object.fromEntries( + Object.entries(record).filter(([, recordValue]) => recordValue !== null && recordValue !== undefined), + ); + +const sourceVersion = (manifest) => + compactObject({ + version: manifest.version, + date: manifest.date, + localizedDescription: manifest.localizedDescription, + downloadURL: manifest.downloadURL, + size: manifest.size, + minOSVersion: manifest.minOSVersion, + maxOSVersion: manifest.maxOSVersion, + }); + +const sourceApp = (ipaFileManifests, generatorOptions, metadataPayload, screenshotFiles) => + compactObject({ + name: "ARMSX2 iOS", + bundleIdentifier, + developerName: "ARMSX2", + subtitle: metadataPayload.app.subtitle ?? "PS2 emulation built for ARM64 iOS.", + localizedDescription: metadataPayload.app.localizedDescription + ?? "ARMSX2 brings PlayStation 2 emulation to iOS with an ARM64-focused build, controller-friendly play, and local-network support for compatible titles.", + iconURL: publicAssetUrl(generatorOptions.baseUrl, "assets/icon.png"), + screenshotURLs: screenshotFiles.map((screenshotFile) => publicAssetUrl(generatorOptions.baseUrl, screenshotFile)), + tintColor: "#2F6FAD", + versions: ipaFileManifests.map(sourceVersion), + permissions: ipaFileManifests[0]?.permissions?.length + ? ipaFileManifests[0].permissions + : undefined, + }); + +const sourcePayload = (ipaFileManifests, generatorOptions, metadataPayload, screenshotFiles) => ({ + name: "ARMSX2 iOS", + identifier: sourceIdentifier, + sourceURL: publicAssetUrl(generatorOptions.baseUrl, "apps.json"), + apps: [sourceApp(ipaFileManifests, generatorOptions, metadataPayload, screenshotFiles)], +}); + +const checksumFileEntry = (manifest) => ({ + fileName: manifest.fileName, + bundleIdentifier: manifest.bundleIdentifier, + version: manifest.version, + buildVersion: manifest.buildVersion, + date: manifest.date, + downloadURL: manifest.downloadURL, + size: manifest.size, + sha256: manifest.sha256, +}); + +const checksumPayload = (ipaFileManifests, generatorOptions) => ({ + sourceIdentifier, + sourceURL: publicAssetUrl(generatorOptions.baseUrl, "apps.json"), + generatedAt: ipaFileManifests[0]?.sourceTimestamp ?? null, + files: ipaFileManifests.map(checksumFileEntry), +}); + +export const generatedBuffers = async (generatorOptions) => { + const metadataPayload = await storeMetadata(generatorOptions); + const inputDirectory = resolve(repositoryRoot, generatorOptions.inputDirectory); + const ipaFilePaths = await findIpaFiles(inputDirectory); + + if (generatorOptions.requireIpa && ipaFilePaths.length === 0) { + throw new SourceGenerationError(`${relative(repositoryRoot, inputDirectory)} contains no IPA files.`); + } + + const ipaFileManifests = []; + + for (const ipaFilePath of ipaFilePaths) { + ipaFileManifests.push(await ipaFileManifest(ipaFilePath, generatorOptions)); + } + + ipaFileManifests.sort(compareIpaManifests); + + const screenshotFiles = await findScreenshotFiles(generatorOptions, metadataPayload); + const githubReleases = await fetchGithubReleases(generatorOptions, metadataPayload); + const existingDescriptions = await existingVersionDescriptions(repositoryRoot, generatorOptions.sourcePath); + + for (const manifest of ipaFileManifests) { + manifest.localizedDescription = storeChangelog( + manifest, + metadataPayload, + githubReleases, + existingDescriptions, + ); + } + + return { + source: jsonBuffer(sourcePayload(ipaFileManifests, generatorOptions, metadataPayload, screenshotFiles)), + checksums: jsonBuffer(checksumPayload(ipaFileManifests, generatorOptions)), + screenshotFiles, + ipaCount: ipaFileManifests.length, + }; +}; diff --git a/scripts/source-utils.js b/scripts/source-utils.js new file mode 100644 index 0000000..e820872 --- /dev/null +++ b/scripts/source-utils.js @@ -0,0 +1,46 @@ +import { readdir, readFile } from "node:fs/promises"; + +export const normalizedPublicBaseUrl = (baseUrl) => { + const publicBaseUrl = new URL(baseUrl); + publicBaseUrl.pathname = publicBaseUrl.pathname.endsWith("/") + ? publicBaseUrl.pathname + : `${publicBaseUrl.pathname}/`; + return publicBaseUrl.href; +}; + +export const publicAssetUrl = (baseUrl, publicRelativePath) => { + const encodedPathSegments = publicRelativePath + .split(/[\\/]/u) + .filter(Boolean) + .map(encodeURIComponent) + .join("/"); + + return new URL(encodedPathSegments, normalizedPublicBaseUrl(baseUrl)).href; +}; + +export const jsonBuffer = (jsonPayload) => + Buffer.from(`${JSON.stringify(jsonPayload, null, 2)}\n`, "utf8"); + +export const optionalJsonDocument = async (jsonPath, fallbackPayload = {}) => { + try { + return JSON.parse(await readFile(jsonPath, "utf8")); + } catch (filesystemError) { + if (filesystemError?.code === "ENOENT") { + return fallbackPayload; + } + + throw filesystemError; + } +}; + +export const readDirectoryEntries = async (inputDirectory) => { + try { + return await readdir(inputDirectory, { withFileTypes: true }); + } catch (filesystemError) { + if (filesystemError?.code === "ENOENT") { + return []; + } + + throw filesystemError; + } +}; diff --git a/scripts/sync-upstream-ipa.js b/scripts/sync-upstream-ipa.js new file mode 100644 index 0000000..4dd0cff --- /dev/null +++ b/scripts/sync-upstream-ipa.js @@ -0,0 +1,289 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { promisify } from "node:util"; + +import { parseOptions, setOptionFlag, setOptionValue } from "./cli.js"; +import { defaultBaseUrl, repositoryRoot } from "./constants.js"; + +const execFileAsync = promisify(execFile); +const generatorPath = resolve(repositoryRoot, "scripts/generate-source.js"); +const validatorPath = resolve(repositoryRoot, "scripts/validate-source.js"); + +class UpstreamSyncError extends Error { + constructor(message) { + super(message); + this.name = "UpstreamSyncError"; + } +} + +const defaults = { + metadataPath: "metadata/store.json", + checksumsPath: "checksums.json", + outputDirectory: "ipas", + baseUrl: process.env.ARMSX2_PUBLIC_BASE_URL || defaultBaseUrl, + upstreamReleaseRepo: process.env.ARMSX2_UPSTREAM_RELEASE_REPO || null, + includePrereleases: process.env.INCLUDE_PRERELEASES === "true", +}; + +const parseArguments = (cliArguments) => parseOptions( + cliArguments, + defaults, + { + "--metadata": setOptionValue("metadataPath"), + "--checksums": setOptionValue("checksumsPath"), + "--output-dir": setOptionValue("outputDirectory"), + "--base-url": setOptionValue("baseUrl"), + "--upstream-release-repo": setOptionValue("upstreamReleaseRepo"), + "--include-prereleases": setOptionFlag("includePrereleases"), + }, + (message) => new UpstreamSyncError(message), +); + +const jsonDocument = async (jsonPath, fallbackPayload = {}) => { + try { + return JSON.parse(await readFile(jsonPath, "utf8")); + } catch (filesystemError) { + if (filesystemError?.code === "ENOENT") { + return fallbackPayload; + } + + throw filesystemError; + } +}; + +const releaseRepository = async (syncOptions) => { + const metadataPath = resolve(repositoryRoot, syncOptions.metadataPath); + const metadataPayload = await jsonDocument(metadataPath); + const repositoryName = syncOptions.upstreamReleaseRepo + ?? metadataPayload.releaseNotes?.upstreamRepository; + + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repositoryName ?? "")) { + throw new UpstreamSyncError(`Invalid upstream release repository: ${repositoryName}`); + } + + return repositoryName; +}; + +const githubHeaders = (accept = "application/vnd.github+json") => { + const headers = { + Accept: accept, + "User-Agent": "J1coding-ARMSX2-Source-Updater/2.1", + }; + + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + + return headers; +}; + +const githubReleases = async (repositoryName) => { + const releaseResponse = await fetch(`https://api.github.com/repos/${repositoryName}/releases?per_page=100`, { + headers: githubHeaders(), + }); + + if (!releaseResponse.ok) { + throw new UpstreamSyncError(`GitHub release lookup failed: ${releaseResponse.status}`); + } + + const releasePayload = await releaseResponse.json(); + + if (!Array.isArray(releasePayload)) { + throw new UpstreamSyncError("GitHub release lookup returned an unexpected payload."); + } + + return releasePayload; +}; + +const releaseTimestamp = (githubRelease) => + String(githubRelease.published_at ?? githubRelease.created_at ?? ""); + +const releaseCandidates = (releases, syncOptions) => + releases + .filter((githubRelease) => !githubRelease.draft) + .filter((githubRelease) => syncOptions.includePrereleases || !githubRelease.prerelease) + .flatMap((githubRelease) => + (githubRelease.assets ?? []) + .filter((releaseAsset) => releaseAsset.name?.toLowerCase().endsWith(".ipa")) + .map((releaseAsset) => ({ githubRelease, releaseAsset })), + ) + .sort((leftCandidate, rightCandidate) => + 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), + ); + + return iosCandidate ?? candidates[0] ?? null; +}; + +const downloadAssetToFile = async (releaseAsset, outputPath) => { + const assetResponse = await fetch(releaseAsset.url, { + headers: githubHeaders("application/octet-stream"), + }); + + if (!assetResponse.ok) { + throw new UpstreamSyncError(`IPA download failed: ${assetResponse.status}`); + } + + if (!assetResponse.body) { + throw new UpstreamSyncError("IPA download returned an empty response body."); + } + + const hash = createHash("sha256"); + const hashingStream = new Transform({ + transform(chunk, _encoding, callback) { + hash.update(chunk); + callback(null, chunk); + }, + }); + + await mkdir(dirname(outputPath), { recursive: true }); + await pipeline( + Readable.fromWeb(assetResponse.body), + hashingStream, + createWriteStream(outputPath), + ); + + return hash.digest("hex"); +}; + +const checksumExists = async (syncOptions, ipaSha256) => { + const checksumPath = resolve(repositoryRoot, syncOptions.checksumsPath); + const checksumPayload = await jsonDocument(checksumPath, { files: [] }); + + return (checksumPayload.files ?? []).some((fileEntry) => fileEntry.sha256 === ipaSha256); +}; + +const safeAssetName = (assetName) => + basename(assetName).replace(/[^\w.-]/gu, "-"); + +const writeGithubOutput = async (outputPayload) => { + if (!process.env.GITHUB_OUTPUT) { + return; + } + + const outputLines = Object.entries(outputPayload) + .map(([outputKey, outputValue]) => `${outputKey}=${String(outputValue).replaceAll("\n", " ")}`) + .join("\n"); + + await writeFile(process.env.GITHUB_OUTPUT, `${outputLines}\n`, { flag: "a" }); +}; + +const runNodeScript = async (scriptPath, scriptArguments, extraEnvironment = {}) => { + await execFileAsync(process.execPath, [scriptPath, ...scriptArguments], { + cwd: repositoryRoot, + env: { + ...process.env, + ...extraEnvironment, + }, + maxBuffer: 1024 * 1024 * 8, + }); +}; + +const validateCandidate = async (temporaryDirectory, candidateIpaDirectory, syncOptions) => { + const candidateSourcePath = join(temporaryDirectory, "apps.json"); + const candidateChecksumsPath = join(temporaryDirectory, "checksums.json"); + + await runNodeScript(generatorPath, [ + "--input-dir", + candidateIpaDirectory, + "--output", + candidateSourcePath, + "--checksums", + candidateChecksumsPath, + "--base-url", + syncOptions.baseUrl, + "--require-ipa", + ]); + + await runNodeScript(validatorPath, [ + "--source", + candidateSourcePath, + "--checksums", + candidateChecksumsPath, + "--skip-offline-fallback", + "--skip-legacy-purge", + ]); +}; + +const replacePublishedIpas = async (candidateIpaPath, syncOptions) => { + const outputDirectory = resolve(repositoryRoot, syncOptions.outputDirectory); + const outputIpaPath = join(outputDirectory, basename(candidateIpaPath)); + + await rm(outputDirectory, { recursive: true, force: true }); + await mkdir(outputDirectory, { recursive: true }); + await copyFile(candidateIpaPath, outputIpaPath); + + return outputIpaPath; +}; + +const syncLatestIpa = async () => { + const syncOptions = parseArguments(process.argv.slice(2)); + const temporaryDirectory = await mkdtemp(join(tmpdir(), "armsx2-upstream-")); + + try { + const repositoryName = await releaseRepository(syncOptions); + const releases = await githubReleases(repositoryName); + const releaseCandidate = selectedReleaseCandidate(releaseCandidates(releases, syncOptions)); + + if (!releaseCandidate) { + throw new UpstreamSyncError(`No IPA release asset found in ${repositoryName}.`); + } + + const { githubRelease, releaseAsset } = releaseCandidate; + const assetName = safeAssetName(releaseAsset.name); + const candidateIpaPath = join(temporaryDirectory, "ipas", assetName); + const versionLabel = githubRelease.tag_name ?? githubRelease.name ?? assetName; + const ipaSha256 = await downloadAssetToFile(releaseAsset, candidateIpaPath); + + if (await checksumExists(syncOptions, ipaSha256)) { + await writeGithubOutput({ + changed: "false", + asset_name: assetName, + version: versionLabel, + sha256: ipaSha256, + }); + console.log(`No update: ${assetName} is already published (${ipaSha256}).`); + return; + } + + await validateCandidate(temporaryDirectory, dirname(candidateIpaPath), syncOptions); + const publishedIpaPath = await replacePublishedIpas(candidateIpaPath, syncOptions); + + await writeGithubOutput({ + changed: "true", + asset_name: assetName, + version: versionLabel, + sha256: ipaSha256, + }); + + console.log(`Published ${publishedIpaPath} from ${repositoryName} (${ipaSha256}).`); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +}; + +try { + await syncLatestIpa(); +} catch (syncError) { + console.error(syncError instanceof Error ? syncError.message : syncError); + process.exitCode = 1; +} diff --git a/scripts/validate-source.js b/scripts/validate-source.js new file mode 100644 index 0000000..c7f98fd --- /dev/null +++ b/scripts/validate-source.js @@ -0,0 +1,368 @@ +#!/usr/bin/env node + +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 { promisify } from "node:util"; + +import { parseOptions, setOptionFlag, setOptionValue } from "./cli.js"; +import { bundleIdentifier, repositoryRoot, sourceIdentifier } from "./constants.js"; + +const execFileAsync = promisify(execFile); +const schemaUrl = "https://raw.githubusercontent.com/SideStore/sidestore-source-types/main/schema.json"; +const generatorPath = resolve(repositoryRoot, "scripts/generate-source.js"); + +const defaults = { + sourcePath: "apps.json", + checksumPath: "checksums.json", + offlineFallback: true, + legacyPurge: true, +}; + +class SourceValidationError extends Error { + constructor(messages) { + super(messages.join("\n")); + this.name = "SourceValidationError"; + } +} + +class ValidationArgumentError extends Error { + constructor(message) { + super(message); + this.name = "ValidationArgumentError"; + } +} + +const parseArguments = (cliArguments) => parseOptions( + cliArguments, + defaults, + { + "--source": setOptionValue("sourcePath"), + "--checksums": setOptionValue("checksumPath"), + "--skip-offline-fallback": setOptionFlag("offlineFallback", false), + "--skip-legacy-purge": setOptionFlag("legacyPurge", false), + }, + (message) => new ValidationArgumentError(message), +); + +const jsonDocument = async (jsonPath) => { + const jsonText = await readFile(jsonPath, "utf8"); + return JSON.parse(jsonText); +}; + +const sideStoreSchema = async () => { + const schemaResponse = await fetch(schemaUrl, { + headers: { + Accept: "application/schema+json, application/json", + "User-Agent": "J1coding-ARMSX2-Source-Validator/2.0", + }, + }); + + if (!schemaResponse.ok) { + throw new SourceValidationError([`SideStore schema fetch failed: ${schemaResponse.status}`]); + } + + return schemaResponse.json(); +}; + +const validateAgainstSideStoreSchema = async (sourceJson) => { + const schemaDocument = await sideStoreSchema(); + const schemaValidator = new Ajv({ allErrors: true, strict: true }); + const validateSource = schemaValidator.compile(schemaDocument); + + if (validateSource(sourceJson)) { + return []; + } + + return validateSource.errors.map((schemaError) => { + const schemaPath = schemaError.instancePath || "$"; + return `${schemaPath} ${schemaError.message}`; + }); +}; + +const nestedKeyPaths = (unknownPayload, forbiddenKey, currentPath = "$") => { + if (Array.isArray(unknownPayload)) { + return unknownPayload.flatMap((nestedPayload, nestedIndex) => + nestedKeyPaths(nestedPayload, forbiddenKey, `${currentPath}[${nestedIndex}]`), + ); + } + + if (!unknownPayload || typeof unknownPayload !== "object") { + return []; + } + + return Object.entries(unknownPayload).flatMap(([payloadKey, nestedPayload]) => { + const matchedPath = payloadKey === forbiddenKey ? [`${currentPath}.${payloadKey}`] : []; + return [...matchedPath, ...nestedKeyPaths(nestedPayload, forbiddenKey, `${currentPath}.${payloadKey}`)]; + }); +}; + +const validateStrictSourceShape = (sourceJson) => { + const errors = []; + + if (sourceJson.identifier !== sourceIdentifier) { + errors.push(`apps.json identifier must be ${sourceIdentifier}.`); + } + + const forbiddenSourceKeys = ["buildVersion", "sha256", "appPermissions", "marketplaceID"]; + + for (const forbiddenKey of forbiddenSourceKeys) { + const matchingPaths = nestedKeyPaths(sourceJson, forbiddenKey); + + if (matchingPaths.length > 0) { + errors.push(`apps.json must not contain ${forbiddenKey}: ${matchingPaths.join(", ")}`); + } + } + + for (const [appIndex, sourceApp] of sourceJson.apps?.entries?.() ?? []) { + if (sourceApp.bundleIdentifier !== bundleIdentifier) { + 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; + } + + for (const [screenshotIndex, screenshotURL] of sourceApp.screenshotURLs.entries()) { + try { + const parsedScreenshotURL = new URL(screenshotURL); + + 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.`); + } + } + } + + return errors; +}; + +const validateChecksumManifest = (sourceJson, checksumJson) => { + const errors = []; + + if (checksumJson.sourceIdentifier !== sourceIdentifier) { + errors.push(`checksums.json sourceIdentifier must be ${sourceIdentifier}.`); + } + + if (checksumJson.sourceURL !== sourceJson.sourceURL) { + errors.push("checksums.json sourceURL must match apps.json sourceURL."); + } + + if (!Array.isArray(checksumJson.files)) { + errors.push("checksums.json files must be an array."); + return errors; + } + + const sourceDownloadURLs = new Set( + (sourceJson.apps ?? []).flatMap((sourceApp) => + (sourceApp.versions ?? []).map((sourceVersion) => sourceVersion.downloadURL), + ), + ); + + for (const [fileIndex, checksumEntry] of checksumJson.files.entries()) { + const checksumPath = `files[${fileIndex}]`; + + if (checksumEntry.bundleIdentifier !== bundleIdentifier) { + errors.push(`${checksumPath}.bundleIdentifier must be ${bundleIdentifier}.`); + } + + if (!/^[0-9a-f]{64}$/u.test(checksumEntry.sha256 ?? "")) { + errors.push(`${checksumPath}.sha256 must be a lowercase SHA-256 hex digest.`); + } + + if (!Number.isSafeInteger(checksumEntry.size) || checksumEntry.size <= 0) { + errors.push(`${checksumPath}.size must be a positive integer.`); + } + + if (typeof checksumEntry.buildVersion !== "string" || checksumEntry.buildVersion.length === 0) { + errors.push(`${checksumPath}.buildVersion must be a non-empty string.`); + } + + if (!sourceDownloadURLs.has(checksumEntry.downloadURL)) { + errors.push(`${checksumPath}.downloadURL is absent from public/apps.json.`); + } + } + + return errors; +}; + +const textExtensions = new Set([ + ".html", + ".js", + ".jsx", + ".json", + ".md", + ".mjs", + ".ts", + ".tsx", + ".txt", + ".yaml", + ".yml", +]); + +const skippedDirectories = new Set([".git", "node_modules", "dist", ".vite"]); +const excludedLegacyScanFiles = new Set(["scripts/validate-source.js"]); +const legacyScanRoots = [ + ".github", + "README.md", + "index.html", + "metadata", + "scripts", + "package.json", + "apps.json", + "checksums.json", + "public/apps.json", + "public/checksums.json", +]; + +const relativeRepositoryPath = (entryPath) => relative(repositoryRoot, entryPath).split(sep).join("/"); + +const isTextFile = (entryPath, entryStats) => + entryStats.size <= 1024 * 1024 + && (textExtensions.has(extname(entryPath).toLowerCase()) || relativeRepositoryPath(entryPath) === ".gitignore"); + +const discoverTextFiles = async (entryPath) => { + const entryRelativePath = relativeRepositoryPath(entryPath); + + if (excludedLegacyScanFiles.has(entryRelativePath)) { + return []; + } + + const entryStats = await stat(entryPath); + + if (entryStats.isFile()) { + return isTextFile(entryPath, entryStats) ? [entryPath] : []; + } + + if (!entryStats.isDirectory()) { + return []; + } + + const directoryEntries = await readdir(entryPath, { withFileTypes: true }); + const discoveredFiles = []; + + for (const directoryEntry of directoryEntries) { + if (directoryEntry.isDirectory() && skippedDirectories.has(directoryEntry.name)) { + continue; + } + + discoveredFiles.push(...await discoverTextFiles(join(entryPath, directoryEntry.name))); + } + + return discoveredFiles; +}; + +const repositoryTextFiles = async () => { + const discoveredFiles = []; + + for (const scanRoot of legacyScanRoots) { + const scanPath = resolve(repositoryRoot, scanRoot); + + try { + discoveredFiles.push(...await discoverTextFiles(scanPath)); + } catch (filesystemError) { + if (filesystemError?.code !== "ENOENT") { + throw filesystemError; + } + } + } + + return [...new Set(discoveredFiles)]; +}; + +const legacyNeedles = [ + "AltStore", + "Android", + ".apk", + "APK", + "PC build", + "Cydia", + "Sileo", + "source.json", + "releases.json", +]; + +const validateLegacyPurge = async () => { + const errors = []; + const textFiles = await repositoryTextFiles(); + + for (const textFilePath of textFiles) { + const repositoryRelativePath = relativeRepositoryPath(textFilePath); + const fileText = await readFile(textFilePath, "utf8"); + + for (const legacyNeedle of legacyNeedles) { + if (fileText.includes(legacyNeedle)) { + errors.push(`${repositoryRelativePath} contains legacy reference: ${legacyNeedle}`); + } + } + } + + return errors; +}; + +const validateOfflineFallback = async () => { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "armsx2-offline-source-")); + + try { + const offlineSourcePath = join(temporaryDirectory, "apps.json"); + const offlineChecksumsPath = join(temporaryDirectory, "checksums.json"); + + await execFileAsync(process.execPath, [ + generatorPath, + "--offline", + "--output", + offlineSourcePath, + "--checksums", + offlineChecksumsPath, + ], { cwd: repositoryRoot }); + + const offlineSource = await jsonDocument(offlineSourcePath); + const descriptions = (offlineSource.apps ?? []) + .flatMap((sourceApp) => sourceApp.versions ?? []) + .map((sourceVersion) => sourceVersion.localizedDescription) + .filter(Boolean); + + if (descriptions.length === 0) { + return ["offline fallback generation produced no version descriptions."]; + } + + if (descriptions.some((description) => !description.startsWith("Updated to ARMSX2 iOS"))) { + return ["offline fallback generation produced an unpolished changelog."]; + } + + return []; + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +}; + +const runValidation = async () => { + const validationOptions = parseArguments(process.argv.slice(2)); + const sourceJson = await jsonDocument(resolve(repositoryRoot, validationOptions.sourcePath)); + const checksumJson = await jsonDocument(resolve(repositoryRoot, validationOptions.checksumPath)); + const errors = [ + ...await validateAgainstSideStoreSchema(sourceJson), + ...validateStrictSourceShape(sourceJson), + ...validateChecksumManifest(sourceJson, checksumJson), + ...(validationOptions.offlineFallback ? await validateOfflineFallback() : []), + ...(validationOptions.legacyPurge ? await validateLegacyPurge() : []), + ]; + + if (errors.length > 0) { + throw new SourceValidationError(errors); + } + + console.log("apps.json validates against the current SideStore schema."); +}; + +try { + await runValidation(); +} catch (validationError) { + console.error(validationError instanceof Error ? validationError.message : validationError); + process.exitCode = 1; +}