feat(mobile-app): prepare native app packaging

This commit is contained in:
2026-07-23 14:40:23 +08:00
parent f6609808df
commit 0f5c2e8667
62 changed files with 979 additions and 833 deletions
+83
View File
@@ -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"
+107
View File
@@ -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).`);