108 lines
4.8 KiB
JavaScript
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).`);
|