Files
prop-ai-hr/mobile-uni/scripts/verify-app-assets.mjs
T

108 lines
4.8 KiB
JavaScript

import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
const projectRoot = resolve(import.meta.dirname, '..');
const manifestPath = resolve(projectRoot, 'src/manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const requirePrivacy = process.argv.includes('--require-privacy');
const failures = [];
const fail = (message) => failures.push(message);
const readPng = (relativePath) => {
if (typeof relativePath !== 'string' || !relativePath) {
fail('manifest is missing an App asset path');
return undefined;
}
const path = resolve(projectRoot, 'src', relativePath);
if (!existsSync(path)) {
fail(`missing asset: ${relativePath}`);
return undefined;
}
const header = readFileSync(path).subarray(0, 29);
const isPng = header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
if (!isPng || header.toString('ascii', 12, 16) !== 'IHDR') {
fail(`invalid PNG: ${relativePath}`);
return undefined;
}
return {
width: header.readUInt32BE(16),
height: header.readUInt32BE(20),
colorType: header[25]
};
};
const expectPng = (relativePath, width, height, opaque = false) => {
const png = readPng(relativePath);
if (!png) return;
if (png.width !== width || png.height !== height) {
fail(`unexpected dimensions for ${relativePath}: got ${png.width}x${png.height}, expected ${width}x${height}`);
}
if (opaque && (png.colorType === 4 || png.colorType === 6)) {
fail(`iOS App icon must not contain alpha: ${relativePath}`);
}
};
const app = manifest['app-plus'];
const distribute = app?.distribute;
const icons = distribute?.icons;
const splashscreen = distribute?.splashscreen;
if (!Array.isArray(app?.screenOrientation) || !app.screenOrientation.includes('portrait-primary')) {
fail('app-plus.screenOrientation must include portrait-primary');
}
const androidIcons = [
['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192]
];
for (const [density, size] of androidIcons) {
expectPng(icons?.android?.[density], size, size);
}
const iosIconPaths = [
[icons?.ios?.appstore, 1024],
[icons?.ios?.iphone?.['app@2x'], 120], [icons?.ios?.iphone?.['app@3x'], 180],
[icons?.ios?.iphone?.['spotlight@2x'], 80], [icons?.ios?.iphone?.['spotlight@3x'], 120],
[icons?.ios?.iphone?.['settings@2x'], 58], [icons?.ios?.iphone?.['settings@3x'], 87],
[icons?.ios?.iphone?.['notification@2x'], 40], [icons?.ios?.iphone?.['notification@3x'], 60],
[icons?.ios?.ipad?.app, 76], [icons?.ios?.ipad?.['app@2x'], 152], [icons?.ios?.ipad?.['proapp@2x'], 167],
[icons?.ios?.ipad?.spotlight, 40], [icons?.ios?.ipad?.['spotlight@2x'], 80],
[icons?.ios?.ipad?.settings, 29], [icons?.ios?.ipad?.['settings@2x'], 58],
[icons?.ios?.ipad?.notification, 20], [icons?.ios?.ipad?.['notification@2x'], 40]
];
for (const [relativePath, size] of iosIconPaths) expectPng(relativePath, size, size, true);
const splashPaths = [
[splashscreen?.android?.hdpi, 480, 762], [splashscreen?.android?.xhdpi, 720, 1242], [splashscreen?.android?.xxhdpi, 1080, 1882],
[splashscreen?.ios?.iphone?.retina40, 640, 1136], [splashscreen?.ios?.iphone?.retina47, 750, 1334],
[splashscreen?.ios?.iphone?.retina55, 1242, 2208], [splashscreen?.ios?.iphone?.iphonex, 1125, 2436],
[splashscreen?.ios?.iphone?.['portrait-896h@2x'], 828, 1792], [splashscreen?.ios?.iphone?.['portrait-896h@3x'], 1242, 2688],
[splashscreen?.ios?.ipad?.portrait7, 768, 1024], [splashscreen?.ios?.ipad?.['portrait-retina7'], 1536, 2048],
[splashscreen?.ios?.ipad?.['portrait-1112h@2x'], 1668, 2224], [splashscreen?.ios?.ipad?.['portrait-1194h@2x'], 1668, 2388],
[splashscreen?.ios?.ipad?.['portrait-1366h@2x'], 2048, 2732]
];
for (const [relativePath, width, height] of splashPaths) expectPng(relativePath, width, height);
const privacyPath = resolve(projectRoot, 'androidPrivacy.json');
if (requirePrivacy) {
if (!existsSync(privacyPath)) {
fail('androidPrivacy.json is required for release; copy androidPrivacy.json.example and replace both legal links');
} else {
try {
const privacy = JSON.parse(readFileSync(privacyPath, 'utf8'));
const links = [...String(privacy.message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]);
if (privacy.prompt !== 'template' || links.length < 2 || links.some((link) => !/^https:\/\//.test(link) || link.includes('__'))) {
fail('androidPrivacy.json must use prompt=template and two final HTTPS legal links');
}
} catch {
fail('androidPrivacy.json must be valid JSON without comments');
}
}
}
if (failures.length) {
console.error(`App asset verification failed:\n- ${failures.join('\n- ')}`);
process.exit(1);
}
console.log(`App asset verification passed (${androidIcons.length + iosIconPaths.length} icons, ${splashPaths.length} splash images).`);