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.
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 632 B After Width: | Height: | Size: 861 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 13 KiB |
@@ -74,6 +74,7 @@
|
||||
"mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs",
|
||||
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
|
||||
"mobile:ios:device": "node scripts/mobile/ios-device.mjs",
|
||||
"mobile:ios:screenshots": "node scripts/playstore/generate-graphics.mjs --app-store-screenshots",
|
||||
"mobile:ios:storefront:check": "node scripts/mobile/validate-app-store.mjs",
|
||||
"mobile:ios:storefront:check-strict": "node scripts/mobile/validate-app-store.mjs --strict",
|
||||
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 57 KiB |
@@ -5,10 +5,9 @@ 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 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";
|
||||
|
||||
@@ -41,6 +40,7 @@ for (const [density, scale] of Object.entries(densityScale)) {
|
||||
{
|
||||
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_foreground.png`,
|
||||
size: foregroundSize,
|
||||
safeZone: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -84,8 +84,19 @@ async function writeIfChanged(relativePath, content) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function renderPng(sourceImage, size) {
|
||||
const image = sourceImage.clone().resize(size, size, Jimp.RESIZE_BICUBIC);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -93,14 +104,24 @@ 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.`);
|
||||
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 targets) {
|
||||
const buffer = await renderPng(sourceImage, target.size);
|
||||
for (const target of imageTargets) {
|
||||
const buffer = await renderPng(sourceImage, target.size, target.safeZone);
|
||||
if (await writeIfChanged(target.relativePath, buffer)) {
|
||||
changed.push(target.relativePath);
|
||||
}
|
||||
@@ -113,12 +134,12 @@ async function main() {
|
||||
}
|
||||
|
||||
if (changed.length === 0) {
|
||||
console.log(`Android icon assets are current (${targets.length + xmlTargets.length} files).`);
|
||||
console.log(`Mobile icon assets are current (${imageTargets.length + xmlTargets.length} files).`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
console.error("Android icon assets are out of date:");
|
||||
console.error("Mobile icon assets are out of date:");
|
||||
for (const relativePath of changed) {
|
||||
console.error(`- ${relativePath}`);
|
||||
}
|
||||
@@ -127,7 +148,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Updated ${changed.length} Android icon asset${changed.length === 1 ? "" : "s"}.`);
|
||||
console.log(`Updated ${changed.length} mobile icon asset${changed.length === 1 ? "" : "s"}.`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -15,10 +15,12 @@ const workspaceRoot = fsSync.existsSync("/home/jeppe/pleno-workspace/frontend/pa
|
||||
? "/home/jeppe/pleno-workspace"
|
||||
: path.resolve(projectRoot, "..");
|
||||
const outputRoot = path.join(workspaceRoot, "playstoregraphics");
|
||||
const appStoreScreenshotRoot = path.join(projectRoot, "fastlane/screenshots/da-DK");
|
||||
const baseUrl = process.env.PLAYSTORE_GRAPHICS_BASE_URL || "http://127.0.0.1:5179";
|
||||
const devPort = new URL(baseUrl).port || "5179";
|
||||
const devHost = new URL(baseUrl).hostname || "127.0.0.1";
|
||||
const generatedAt = new Date().toISOString();
|
||||
const appStoreScreenshotsOnly = process.argv.includes("--app-store-screenshots");
|
||||
|
||||
const disallowedRoutePatterns = [
|
||||
/^\/admin(?:\/|$)/i,
|
||||
@@ -81,6 +83,29 @@ const deviceProfiles = [
|
||||
},
|
||||
];
|
||||
|
||||
const appStoreDeviceProfiles = [
|
||||
{
|
||||
key: "iphone-6.9",
|
||||
outputRoot: appStoreScreenshotRoot,
|
||||
fileNamePrefix: "iphone-6.9-",
|
||||
viewport: { width: 440, height: 956 },
|
||||
deviceScaleFactor: 3,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
expected: { width: 1320, height: 2868 },
|
||||
},
|
||||
{
|
||||
key: "ipad-13",
|
||||
outputRoot: appStoreScreenshotRoot,
|
||||
fileNamePrefix: "ipad-13-",
|
||||
viewport: { width: 768, height: 1024 },
|
||||
deviceScaleFactor: 2.6875,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
expected: { width: 2064, height: 2752 },
|
||||
},
|
||||
];
|
||||
|
||||
const screenshotScenes = [
|
||||
{
|
||||
key: "dashboard",
|
||||
@@ -197,7 +222,7 @@ const screenshotScenes = [
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
if (device?.key === "phone") {
|
||||
if (device?.key === "phone" || device?.key === "iphone-6.9") {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const nextButton = document.querySelector('[data-testid="booking-mobile-next"]');
|
||||
@@ -362,7 +387,7 @@ const products = [
|
||||
const demoPosFixture = createPosFixture({
|
||||
departments: [
|
||||
{ id: 1, name: "Roskilde", exclude_from_invoicing: false },
|
||||
{ id: 12, name: "Taastrup", exclude_from_invoicing: false },
|
||||
{ id: 12, name: "Taastrup", exclude_from_invoicing: false, bookingsystem_time_based_enabled: true },
|
||||
],
|
||||
products,
|
||||
departmentCategories: [
|
||||
@@ -726,7 +751,10 @@ const demoSelfServe = {
|
||||
};
|
||||
|
||||
async function ensureDirectories() {
|
||||
await Promise.all(requiredDirectories.map((directory) => fs.mkdir(path.join(outputRoot, directory), { recursive: true })));
|
||||
await Promise.all([
|
||||
...requiredDirectories.map((directory) => fs.mkdir(path.join(outputRoot, directory), { recursive: true })),
|
||||
fs.mkdir(appStoreScreenshotRoot, { recursive: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
function isDisallowedRoute(routePath) {
|
||||
@@ -857,7 +885,7 @@ async function installCommonPageState(context, page) {
|
||||
async function setupMockApi(page, mode) {
|
||||
const baseOptions = {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
permissions: ["user", "add_own_department_selfserve_vehicle_conditions"],
|
||||
sessionData: demoSession,
|
||||
edgeGateways: false,
|
||||
workerVersion: "playstore-graphics",
|
||||
@@ -977,7 +1005,12 @@ async function captureScene(browser, device, scene) {
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(350);
|
||||
|
||||
const outputPath = path.join(outputRoot, device.screenshotDir, scene.fileName);
|
||||
const captureRoot = device.outputRoot || outputRoot;
|
||||
const outputPath = path.join(
|
||||
captureRoot,
|
||||
device.screenshotDir || "",
|
||||
`${device.fileNamePrefix || ""}${scene.fileName}`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: outputPath,
|
||||
type: "jpeg",
|
||||
@@ -993,7 +1026,8 @@ async function captureScene(browser, device, scene) {
|
||||
type: "screenshot",
|
||||
scene: scene.key,
|
||||
route: currentPath,
|
||||
path: path.relative(outputRoot, outputPath),
|
||||
path: path.relative(captureRoot, outputPath),
|
||||
...(device.outputRoot ? { absolutePath: outputPath } : {}),
|
||||
altText: scene.altText,
|
||||
expected: device.expected,
|
||||
};
|
||||
@@ -1400,7 +1434,7 @@ async function createFeatureGraphicVariants(browser) {
|
||||
}
|
||||
|
||||
async function validateAsset(entry) {
|
||||
const absolutePath = path.join(outputRoot, entry.path);
|
||||
const absolutePath = entry.absolutePath || path.join(outputRoot, entry.path);
|
||||
const metadata = await readImageMetadata(absolutePath);
|
||||
if (metadata.width !== entry.expected.width || metadata.height !== entry.expected.height) {
|
||||
throw new Error(
|
||||
@@ -1472,17 +1506,23 @@ async function run() {
|
||||
const entries = [];
|
||||
|
||||
try {
|
||||
entries.push(await copyAndValidateIcon());
|
||||
|
||||
for (const device of deviceProfiles) {
|
||||
const captureDevices = appStoreScreenshotsOnly ? appStoreDeviceProfiles : deviceProfiles;
|
||||
for (const device of captureDevices) {
|
||||
for (const scene of screenshotScenes) {
|
||||
entries.push(await captureScene(browser, device, scene));
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(await createFeatureGraphic(browser));
|
||||
entries.push(...(await createFeatureGraphicVariants(browser)));
|
||||
await writeMetadata(entries);
|
||||
if (appStoreScreenshotsOnly) {
|
||||
for (const entry of entries) {
|
||||
await validateAsset(entry);
|
||||
}
|
||||
} else {
|
||||
entries.unshift(await copyAndValidateIcon());
|
||||
entries.push(await createFeatureGraphic(browser));
|
||||
entries.push(...(await createFeatureGraphicVariants(browser)));
|
||||
await writeMetadata(entries);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (server) {
|
||||
@@ -1494,7 +1534,11 @@ async function run() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Generated ${entries.length} Play Store graphics in ${outputRoot}`);
|
||||
if (appStoreScreenshotsOnly) {
|
||||
console.log(`Generated ${entries.length} App Store screenshots in ${appStoreScreenshotRoot}`);
|
||||
} else {
|
||||
console.log(`Generated ${entries.length} Play Store graphics in ${outputRoot}`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 57 KiB |