169 lines
6.4 KiB
JavaScript
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" : ""}.`);
|