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), resolve(projectRoot, relativePath) ].find(existsSync); if (!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).`);