Files
pleno-vue/scripts/mobile/validate-app-store.mjs
T
Jeppe B 7d5ec8894c autoheal(ios): align validate-app-store.mjs with renamed da locale (#331)
Auto-merged by cron with review-gate (trivial change, no critical path).
2026-08-17 14:20:51 +02:00

169 lines
6.4 KiB
JavaScript

import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { basename, extname, join, relative } from "node:path";
import { argv, exit } from "node:process";
const strict = argv.includes("--strict");
const failures = [];
const warnings = [];
const root = process.cwd();
// App Store Connect uses the bare `da` locale code for Danish (not `da-DK`).
// Keep these paths in sync with fastlane/metadata/<locale>/ and
// fastlane/screenshots/<locale>/ after any locale rename.
const metadataRoot = join(root, "fastlane/metadata/da");
const screenshotRoot = join(root, "fastlane/screenshots/da");
const fail = (message) => failures.push(message);
const warn = (message) => warnings.push(message);
const readText = (path) => {
if (!existsSync(path)) {
fail(`Missing ${relative(root, path)}`);
return "";
}
const value = readFileSync(path, "utf8").trim();
if (!value) fail(`${relative(root, path)} must not be empty`);
return value;
};
let release;
try {
release = JSON.parse(readFileSync(join(root, "ios/release.json"), "utf8"));
} catch {
fail("ios/release.json must be valid JSON");
release = {};
}
if (!/^\d+\.\d+\.\d+$/.test(release.marketingVersion ?? "")) {
fail("ios/release.json marketingVersion must be numeric SemVer (X.Y.Z)");
}
if (release.bundleId !== "io.truckwash.app") fail("ios/release.json bundleId must be io.truckwash.app");
if (release.minimumIosVersion !== "15.0")
fail("ios/release.json minimumIosVersion must remain 15.0 unless compatibility is intentionally changed");
const name = readText(join(metadataRoot, "name.txt"));
const subtitle = readText(join(metadataRoot, "subtitle.txt"));
const promotionalText = readText(join(metadataRoot, "promotional_text.txt"));
const keywords = readText(join(metadataRoot, "keywords.txt"));
const description = readText(join(metadataRoot, "description.txt"));
readText(join(metadataRoot, "release_notes.txt"));
readText(join(root, "fastlane/metadata/copyright.txt"));
if ([...name].length > 30) fail("App Store name exceeds 30 characters");
if ([...subtitle].length > 30) fail("App Store subtitle exceeds 30 characters");
if ([...promotionalText].length > 170) fail("Promotional text exceeds 170 characters");
if (Buffer.byteLength(keywords, "utf8") > 100) fail("Keywords exceed Apple's 100-byte limit");
if ([...description].length > 4000) fail("Description exceeds 4,000 characters");
for (const file of ["support_url.txt", "privacy_url.txt", "marketing_url.txt"]) {
const value = readText(join(metadataRoot, file));
try {
const url = new URL(value);
if (url.protocol !== "https:") fail(`${file} must use HTTPS`);
} catch {
fail(`${file} must contain a valid URL`);
}
}
const knownCapacitorArtwork = new Set([
"29e4777e319de3ee5a52c3a8004ec19d0568414004257e36d7c94a077d71c93b",
"1b5002b74a5500e697298ced06ca2811ac33f2771f236f3c720ff23243890530",
]);
for (const path of [
join(root, "ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"),
join(root, "ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png"),
]) {
if (!existsSync(path)) {
fail(`Missing ${relative(root, path)}`);
continue;
}
const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
if (knownCapacitorArtwork.has(digest)) {
fail(`${relative(root, path)} is still the default Capacitor artwork`);
}
}
const pngInfo = (buffer) => {
if (buffer.length < 33 || buffer.subarray(1, 4).toString("ascii") !== "PNG") return null;
const colorType = buffer[25];
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
hasAlpha: colorType === 4 || colorType === 6,
};
};
const jpegInfo = (buffer) => {
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = buffer[offset + 1];
if (marker === 0xd8 || marker === 0xd9) {
offset += 2;
continue;
}
const length = buffer.readUInt16BE(offset + 2);
if (length < 2 || offset + 2 + length > buffer.length) break;
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5), hasAlpha: false };
}
offset += 2 + length;
}
return null;
};
const screenshots = existsSync(screenshotRoot)
? readdirSync(screenshotRoot)
.filter((file) => [".png", ".jpg", ".jpeg"].includes(extname(file).toLowerCase()))
.map((file) => join(screenshotRoot, file))
: [];
const groups = { iphone: [], ipad: [] };
const hashes = new Map();
for (const path of screenshots) {
const filename = basename(path);
const group = filename.startsWith("iphone-6.9-") ? "iphone" : filename.startsWith("ipad-13-") ? "ipad" : null;
if (!group) {
fail(`${filename} must start with iphone-6.9- or ipad-13-`);
continue;
}
if (statSync(path).size === 0) {
fail(`${filename} is empty`);
continue;
}
const buffer = readFileSync(path);
const info = pngInfo(buffer) ?? jpegInfo(buffer);
if (!info) {
fail(`${filename} is not a readable PNG or JPEG`);
continue;
}
const expected = group === "iphone" ? [1320, 2868] : [2064, 2752];
if (info.width !== expected[0] || info.height !== expected[1]) {
fail(`${filename} is ${info.width}x${info.height}; expected ${expected[0]}x${expected[1]}`);
}
if (info.hasAlpha) fail(`${filename} has an alpha channel, which App Store screenshots must not use`);
const digest = createHash("sha256").update(buffer).digest("hex");
if (hashes.has(digest)) fail(`${filename} duplicates ${hashes.get(digest)}`);
hashes.set(digest, filename);
groups[group].push(filename);
}
for (const [group, files] of Object.entries(groups)) {
if (files.length !== 6) {
const message = `Expected 6 ${group === "iphone" ? "iPhone 6.9-inch" : "iPad 13-inch"} screenshots; found ${
files.length
}`;
if (strict) fail(message);
else warn(message);
}
}
for (const message of warnings) console.warn(`Storefront readiness warning: ${message}`);
if (failures.length > 0) {
console.error("App Store validation failed:");
for (const message of failures) console.error(`- ${message}`);
exit(1);
}
console.log(`App Store metadata is valid${strict ? " and candidate assets are complete" : ""}.`);