Files
pleno-vue/scripts/mobile/generate-android-icons.mjs
T
Jeppe B 97df3193e3 Complete App Store artwork and signed release automation (#200)
Align generated artwork with the published Truck Wash storefront, add strict iPhone and iPad App Store screenshots, and complete signed iOS release automation.
2026-07-20 18:19:47 +00:00

158 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Jimp from "jimp";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
const iosIconDirectory = path.join(projectRoot, "ios/App/App/Assets.xcassets/AppIcon.appiconset");
const iosContentsPath = path.join(iosIconDirectory, "Contents.json");
const checkOnly = process.argv.includes("--check");
const launcherBackground = "#FFFFFF";
const densityScale = {
mdpi: 1,
hdpi: 1.5,
xhdpi: 2,
xxhdpi: 3,
xxxhdpi: 4,
};
const targets = [
{ relativePath: "public/icons/icon-512x512.png", size: 512 },
{ relativePath: "public/icons/icon-192x192.png", size: 192 },
{ relativePath: "store_icon.png", size: 512 },
];
for (const [density, scale] of Object.entries(densityScale)) {
const legacySize = Math.round(48 * scale);
const foregroundSize = Math.round(108 * scale);
targets.push(
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher.png`,
size: legacySize,
},
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_round.png`,
size: legacySize,
},
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_foreground.png`,
size: foregroundSize,
safeZone: true,
},
);
}
const xmlTargets = [
{
relativePath: "android/app/src/main/res/values/ic_launcher_background.xml",
content: `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${launcherBackground}</color>\n</resources>\n`,
},
];
function targetPath(relativePath) {
return path.join(projectRoot, relativePath);
}
async function readIfExists(filePath) {
try {
return await fs.readFile(filePath);
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
throw error;
}
}
async function writeIfChanged(relativePath, content) {
const filePath = targetPath(relativePath);
const current = await readIfExists(filePath);
if (current?.equals(content)) {
return false;
}
if (checkOnly) {
return true;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
return true;
}
async function renderPng(sourceImage, size, safeZone = false) {
const image = new Jimp(size, size, launcherBackground);
const artworkSize = safeZone ? Math.round(size * 0.72) : size;
const artwork = sourceImage.clone();
if (artwork.bitmap.width !== artworkSize || artwork.bitmap.height !== artworkSize) {
artwork.resize(artworkSize, artworkSize, Jimp.RESIZE_BICUBIC);
}
const offset = Math.round((size - artworkSize) / 2);
image.composite(artwork, offset, offset);
image.scan(0, 0, size, size, (_x, _y, index) => {
image.bitmap.data[index + 3] = 255;
});
image.colorType(2);
return image.getBufferAsync(Jimp.MIME_PNG);
}
async function main() {
const sourceBuffer = await fs.readFile(sourcePath);
const sourceImage = await Jimp.read(sourceBuffer);
if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 512x512 PNG.`);
}
const iosContents = JSON.parse(await fs.readFile(iosContentsPath, "utf8"));
const iosTargets = iosContents.images.map(({ filename, scale, size }) => ({
relativePath: path.relative(projectRoot, path.join(iosIconDirectory, filename)),
size: Math.round(Number.parseFloat(size) * Number.parseFloat(scale)),
}));
iosTargets.push({
relativePath: path.relative(projectRoot, path.join(iosIconDirectory, "AppIcon-512@2x.png")),
size: 1024,
});
const imageTargets = [...targets, ...iosTargets];
const changed = [];
for (const target of imageTargets) {
const buffer = await renderPng(sourceImage, target.size, target.safeZone);
if (await writeIfChanged(target.relativePath, buffer)) {
changed.push(target.relativePath);
}
}
for (const target of xmlTargets) {
if (await writeIfChanged(target.relativePath, Buffer.from(target.content))) {
changed.push(target.relativePath);
}
}
if (changed.length === 0) {
console.log(`Mobile icon assets are current (${imageTargets.length + xmlTargets.length} files).`);
return;
}
if (checkOnly) {
console.error("Mobile icon assets are out of date:");
for (const relativePath of changed) {
console.error(`- ${relativePath}`);
}
console.error("Run `npm run mobile:android:icons`.");
process.exitCode = 1;
return;
}
console.log(`Updated ${changed.length} mobile icon asset${changed.length === 1 ? "" : "s"}.`);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});