Files
pleno-vue/scripts/mobile/generate-android-icons.mjs
T
Jeppe B 88eda43560 Automate signed iOS App Store releases (#192)
## What changed

- adds production iOS identity, localized storefront metadata, native
privacy declarations, App Store-safe artwork, and account-deletion UX
- mirrors the live Danish Google Play title, short description, and long
description in the App Store metadata source
- generates Android launcher/store icons from the opaque iOS marketing
master so both platforms use the same white background
- adds guarded GitHub Actions workflows for storefront readiness,
credential health, signed TestFlight uploads, and App Store candidate
preparation
- adds pinned Fastlane configuration with a committed dependency lock,
release manifest tooling, and an operational App Store runbook
- preserves the upstream iOS safe-area implementation while retaining
opaque App Store icon assets

## Why

The repository previously supported development-signed device bundles
but had no production App Store identity, reproducible storefront source
of truth, or protected signed-release pipeline. Apple also requires
in-app account deletion for apps that support account creation. The
Android icon master was transparent, which rendered as black on dark
store/device surfaces.

## Impact

Automation remains fail-closed behind
`APP_STORE_AUTOMATION_ENABLED=false`. No build can upload to TestFlight
or change App Store metadata until the switch is deliberately enabled
after merge and the remaining release gates are satisfied.

## Validation

- focused App Store, iOS icon, and cross-platform icon-background tests
pass
- every generated Android store/launcher icon is opaque with pure-white
corners; iOS marketing artwork is checked the same way
- Android icon drift check passes for all 19 generated files
- production Vite build and the broader focused release checks completed
successfully
- storefront metadata is valid; only the two expected screenshot-set
warnings remain
- App Store Readiness is green at head `4445fecc`
- Apple Distribution certificate and App Store profile were
independently verified for `HP3FJ4GVL7.io.truckwash.app`
- live App Store Connect API authentication succeeded for app
`6792777794`
- App Store record, free Denmark-only availability, and automatic
`Internal QA` TestFlight group are configured
- EU trader status, Content Rights, 4+ age rating, and the published App
Privacy label are completed in App Store Connect
- iPhone and iPad accessibility declarations are configured honestly as
pre-release drafts

## Remaining external gates

- reviewed iPhone and iPad screenshot sets are still required
- an App Review login must be supplied without creating or exposing
customer credentials
- the first signed TestFlight candidate must run after merge and
deliberate automation enablement
2026-07-20 17:59:43 +02:00

137 lines
3.6 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,
"ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
);
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,
},
);
}
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) {
const image = sourceImage.clone().resize(size, size, Jimp.RESIZE_BICUBIC);
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 !== 1024 || sourceImage.bitmap.height !== 1024) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 1024x1024 PNG.`);
}
const changed = [];
for (const target of targets) {
const buffer = await renderPng(sourceImage, target.size);
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(`Android icon assets are current (${targets.length + xmlTargets.length} files).`);
return;
}
if (checkOnly) {
console.error("Android 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} Android icon asset${changed.length === 1 ? "" : "s"}.`);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});