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// and // fastlane/screenshots// 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" : ""}.`);