feat(mobile-app): prepare native app packaging
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
brand_logo="$project_root/src/static/brand-logo.png"
|
||||
icons="$project_root/src/static/app/icons"
|
||||
splash="$project_root/src/static/app/splash"
|
||||
|
||||
if ! command -v magick >/dev/null 2>&1; then
|
||||
echo "ImageMagick (magick) is required to generate App assets." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$brand_logo" ]; then
|
||||
echo "Brand logo not found: $brand_logo" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$icons" "$splash"
|
||||
|
||||
make_icon() {
|
||||
size=$1
|
||||
logo_size=$2
|
||||
output=$3
|
||||
magick -size "${size}x${size}" xc:'#FFFFFF' \
|
||||
\( "$brand_logo" -resize "${logo_size}x${logo_size}" \) \
|
||||
-gravity center -compose over -composite -alpha off -strip "$output"
|
||||
}
|
||||
|
||||
make_splash() {
|
||||
canvas=$1
|
||||
logo_size=$2
|
||||
output=$3
|
||||
magick -size "$canvas" xc:'#F7F8FA' \
|
||||
\( "$brand_logo" -resize "${logo_size}x${logo_size}" \) \
|
||||
-gravity center -compose over -composite -alpha off -strip "$output"
|
||||
}
|
||||
|
||||
# All launch and App Store icons are opaque PNGs. iOS applies its own rounded
|
||||
# mask; source artwork must remain a square without alpha information.
|
||||
make_icon 1024 620 "$icons/ios-appstore.png"
|
||||
make_icon 192 118 "$icons/android-xxxhdpi.png"
|
||||
make_icon 144 88 "$icons/android-xxhdpi.png"
|
||||
make_icon 96 60 "$icons/android-xhdpi.png"
|
||||
make_icon 72 44 "$icons/android-hdpi.png"
|
||||
|
||||
make_icon 180 110 "$icons/ios-iphone-app-3x.png"
|
||||
make_icon 120 74 "$icons/ios-iphone-app-2x.png"
|
||||
make_icon 120 74 "$icons/ios-iphone-spotlight-3x.png"
|
||||
make_icon 80 50 "$icons/ios-iphone-spotlight-2x.png"
|
||||
make_icon 87 54 "$icons/ios-iphone-settings-3x.png"
|
||||
make_icon 58 36 "$icons/ios-iphone-settings-2x.png"
|
||||
make_icon 60 38 "$icons/ios-iphone-notification-3x.png"
|
||||
make_icon 40 26 "$icons/ios-iphone-notification-2x.png"
|
||||
|
||||
make_icon 167 104 "$icons/ios-ipad-pro-app-2x.png"
|
||||
make_icon 152 94 "$icons/ios-ipad-app-2x.png"
|
||||
make_icon 76 48 "$icons/ios-ipad-app.png"
|
||||
make_icon 80 50 "$icons/ios-ipad-spotlight-2x.png"
|
||||
make_icon 40 26 "$icons/ios-ipad-spotlight.png"
|
||||
make_icon 58 36 "$icons/ios-ipad-settings-2x.png"
|
||||
make_icon 29 18 "$icons/ios-ipad-settings.png"
|
||||
make_icon 40 26 "$icons/ios-ipad-notification-2x.png"
|
||||
make_icon 20 14 "$icons/ios-ipad-notification.png"
|
||||
|
||||
make_splash '480x762' 160 "$splash/android-hdpi.png"
|
||||
make_splash '720x1242' 240 "$splash/android-xhdpi.png"
|
||||
make_splash '1080x1882' 320 "$splash/android-xxhdpi.png"
|
||||
|
||||
make_splash '640x1136' 200 "$splash/ios-iphone-retina40.png"
|
||||
make_splash '750x1334' 230 "$splash/ios-iphone-retina47.png"
|
||||
make_splash '1242x2208' 360 "$splash/ios-iphone-retina55.png"
|
||||
make_splash '1125x2436' 330 "$splash/ios-iphone-x.png"
|
||||
make_splash '828x1792' 250 "$splash/ios-iphone-896h-2x.png"
|
||||
make_splash '1242x2688' 370 "$splash/ios-iphone-896h-3x.png"
|
||||
|
||||
make_splash '768x1024' 230 "$splash/ios-ipad-portrait7.png"
|
||||
make_splash '1536x2048' 460 "$splash/ios-ipad-portrait-retina7.png"
|
||||
make_splash '1668x2224' 500 "$splash/ios-ipad-1112h-2x.png"
|
||||
make_splash '1668x2388' 500 "$splash/ios-ipad-1194h-2x.png"
|
||||
make_splash '2048x2732' 610 "$splash/ios-ipad-1366h-2x.png"
|
||||
|
||||
echo "Generated App icons and portrait splash images from src/static/brand-logo.png"
|
||||
@@ -0,0 +1,107 @@
|
||||
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).`);
|
||||
Reference in New Issue
Block a user