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:android:play-upload": "node scripts/mobile/upload-google-play.mjs",
|
||||||
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
|
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
|
||||||
"mobile:ios:device": "node scripts/mobile/ios-device.mjs",
|
"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": "node scripts/mobile/validate-app-store.mjs",
|
||||||
"mobile:ios:storefront:check-strict": "node scripts/mobile/validate-app-store.mjs --strict",
|
"mobile:ios:storefront:check-strict": "node scripts/mobile/validate-app-store.mjs --strict",
|
||||||
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
|
"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";
|
import Jimp from "jimp";
|
||||||
|
|
||||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const sourcePath = path.join(
|
const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
|
||||||
projectRoot,
|
const iosIconDirectory = path.join(projectRoot, "ios/App/App/Assets.xcassets/AppIcon.appiconset");
|
||||||
"ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
|
const iosContentsPath = path.join(iosIconDirectory, "Contents.json");
|
||||||
);
|
|
||||||
const checkOnly = process.argv.includes("--check");
|
const checkOnly = process.argv.includes("--check");
|
||||||
const launcherBackground = "#FFFFFF";
|
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`,
|
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_foreground.png`,
|
||||||
size: foregroundSize,
|
size: foregroundSize,
|
||||||
|
safeZone: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,19 @@ async function writeIfChanged(relativePath, content) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPng(sourceImage, size) {
|
async function renderPng(sourceImage, size, safeZone = false) {
|
||||||
const image = sourceImage.clone().resize(size, size, Jimp.RESIZE_BICUBIC);
|
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);
|
return image.getBufferAsync(Jimp.MIME_PNG);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,14 +104,24 @@ async function main() {
|
|||||||
const sourceBuffer = await fs.readFile(sourcePath);
|
const sourceBuffer = await fs.readFile(sourcePath);
|
||||||
const sourceImage = await Jimp.read(sourceBuffer);
|
const sourceImage = await Jimp.read(sourceBuffer);
|
||||||
|
|
||||||
if (sourceImage.bitmap.width !== 1024 || sourceImage.bitmap.height !== 1024) {
|
if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
|
||||||
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 1024x1024 PNG.`);
|
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 = [];
|
const changed = [];
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of imageTargets) {
|
||||||
const buffer = await renderPng(sourceImage, target.size);
|
const buffer = await renderPng(sourceImage, target.size, target.safeZone);
|
||||||
if (await writeIfChanged(target.relativePath, buffer)) {
|
if (await writeIfChanged(target.relativePath, buffer)) {
|
||||||
changed.push(target.relativePath);
|
changed.push(target.relativePath);
|
||||||
}
|
}
|
||||||
@@ -113,12 +134,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changed.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkOnly) {
|
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) {
|
for (const relativePath of changed) {
|
||||||
console.error(`- ${relativePath}`);
|
console.error(`- ${relativePath}`);
|
||||||
}
|
}
|
||||||
@@ -127,7 +148,7 @@ async function main() {
|
|||||||
return;
|
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) => {
|
main().catch((error) => {
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ const workspaceRoot = fsSync.existsSync("/home/jeppe/pleno-workspace/frontend/pa
|
|||||||
? "/home/jeppe/pleno-workspace"
|
? "/home/jeppe/pleno-workspace"
|
||||||
: path.resolve(projectRoot, "..");
|
: path.resolve(projectRoot, "..");
|
||||||
const outputRoot = path.join(workspaceRoot, "playstoregraphics");
|
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 baseUrl = process.env.PLAYSTORE_GRAPHICS_BASE_URL || "http://127.0.0.1:5179";
|
||||||
const devPort = new URL(baseUrl).port || "5179";
|
const devPort = new URL(baseUrl).port || "5179";
|
||||||
const devHost = new URL(baseUrl).hostname || "127.0.0.1";
|
const devHost = new URL(baseUrl).hostname || "127.0.0.1";
|
||||||
const generatedAt = new Date().toISOString();
|
const generatedAt = new Date().toISOString();
|
||||||
|
const appStoreScreenshotsOnly = process.argv.includes("--app-store-screenshots");
|
||||||
|
|
||||||
const disallowedRoutePatterns = [
|
const disallowedRoutePatterns = [
|
||||||
/^\/admin(?:\/|$)/i,
|
/^\/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 = [
|
const screenshotScenes = [
|
||||||
{
|
{
|
||||||
key: "dashboard",
|
key: "dashboard",
|
||||||
@@ -197,7 +222,7 @@ const screenshotScenes = [
|
|||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (device?.key === "phone") {
|
if (device?.key === "phone" || device?.key === "iphone-6.9") {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => {
|
() => {
|
||||||
const nextButton = document.querySelector('[data-testid="booking-mobile-next"]');
|
const nextButton = document.querySelector('[data-testid="booking-mobile-next"]');
|
||||||
@@ -362,7 +387,7 @@ const products = [
|
|||||||
const demoPosFixture = createPosFixture({
|
const demoPosFixture = createPosFixture({
|
||||||
departments: [
|
departments: [
|
||||||
{ id: 1, name: "Roskilde", exclude_from_invoicing: false },
|
{ 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,
|
products,
|
||||||
departmentCategories: [
|
departmentCategories: [
|
||||||
@@ -726,7 +751,10 @@ const demoSelfServe = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function ensureDirectories() {
|
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) {
|
function isDisallowedRoute(routePath) {
|
||||||
@@ -857,7 +885,7 @@ async function installCommonPageState(context, page) {
|
|||||||
async function setupMockApi(page, mode) {
|
async function setupMockApi(page, mode) {
|
||||||
const baseOptions = {
|
const baseOptions = {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
permissions: ["user"],
|
permissions: ["user", "add_own_department_selfserve_vehicle_conditions"],
|
||||||
sessionData: demoSession,
|
sessionData: demoSession,
|
||||||
edgeGateways: false,
|
edgeGateways: false,
|
||||||
workerVersion: "playstore-graphics",
|
workerVersion: "playstore-graphics",
|
||||||
@@ -977,7 +1005,12 @@ async function captureScene(browser, device, scene) {
|
|||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
await page.waitForTimeout(350);
|
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({
|
await page.screenshot({
|
||||||
path: outputPath,
|
path: outputPath,
|
||||||
type: "jpeg",
|
type: "jpeg",
|
||||||
@@ -993,7 +1026,8 @@ async function captureScene(browser, device, scene) {
|
|||||||
type: "screenshot",
|
type: "screenshot",
|
||||||
scene: scene.key,
|
scene: scene.key,
|
||||||
route: currentPath,
|
route: currentPath,
|
||||||
path: path.relative(outputRoot, outputPath),
|
path: path.relative(captureRoot, outputPath),
|
||||||
|
...(device.outputRoot ? { absolutePath: outputPath } : {}),
|
||||||
altText: scene.altText,
|
altText: scene.altText,
|
||||||
expected: device.expected,
|
expected: device.expected,
|
||||||
};
|
};
|
||||||
@@ -1400,7 +1434,7 @@ async function createFeatureGraphicVariants(browser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function validateAsset(entry) {
|
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);
|
const metadata = await readImageMetadata(absolutePath);
|
||||||
if (metadata.width !== entry.expected.width || metadata.height !== entry.expected.height) {
|
if (metadata.width !== entry.expected.width || metadata.height !== entry.expected.height) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1472,17 +1506,23 @@ async function run() {
|
|||||||
const entries = [];
|
const entries = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
entries.push(await copyAndValidateIcon());
|
const captureDevices = appStoreScreenshotsOnly ? appStoreDeviceProfiles : deviceProfiles;
|
||||||
|
for (const device of captureDevices) {
|
||||||
for (const device of deviceProfiles) {
|
|
||||||
for (const scene of screenshotScenes) {
|
for (const scene of screenshotScenes) {
|
||||||
entries.push(await captureScene(browser, device, scene));
|
entries.push(await captureScene(browser, device, scene));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push(await createFeatureGraphic(browser));
|
if (appStoreScreenshotsOnly) {
|
||||||
entries.push(...(await createFeatureGraphicVariants(browser)));
|
for (const entry of entries) {
|
||||||
await writeMetadata(entries);
|
await validateAsset(entry);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entries.unshift(await copyAndValidateIcon());
|
||||||
|
entries.push(await createFeatureGraphic(browser));
|
||||||
|
entries.push(...(await createFeatureGraphicVariants(browser)));
|
||||||
|
await writeMetadata(entries);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
if (server) {
|
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) => {
|
run().catch((error) => {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 57 KiB |