Align generated artwork with the published Truck Wash storefront, add strict iPhone and iPad App Store screenshots, and complete signed iOS release automation.
158 lines
4.8 KiB
JavaScript
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;
|
|
});
|