storebound/cli/apple/screenshots.ts

184 lines
14 KiB
TypeScript

// Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match
// by MD5 and order is left alone, otherwise it is emptied and re-uploaded).
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry]
// storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)
// Source per language: <renders>/<language id>/ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67),
// ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 ->
// APP_IPAD_PRO_3GEN_129: the API has no "13 inch" value, the 13" display set keeps the 3rd gen 12.9" name). With
// --platform macos (or when only the macOS version is editable) and apple.mac: mac/NN.png (2880 x 1800 -> APP_DESKTOP)
// to the macOS version. iPhone Duo (apple.iphoneDuo: iphone-duo-outer/ then iphone-duo-inner/) goes through the App Asset Library. Up to 10
// per set. --variant <id> uploads <renders>/<id>/<lang>/... instead (an A/B variant from shots.variants, e.g. for a
// Product Page Optimization treatment you then set up in App Store Connect). Apple processes uploads asynchronously; failed
// ones are reported at the end. Long: run in the background with a log file.
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { byAppleLocale, need } from '../lib/ctx';
import { done, log, warn } from '../lib/log';
import { numberedImages } from '../play/images';
import { variantRoot } from '../shots/lib';
import { findOrCreate } from '../lib/upsert';
import { pulledChecksum } from '../lib/art-pull';
import { consoleLink } from '../lib/console-links';
import { IPHONE_DUO_DISPLAY } from '../shots/spec';
import { duoGroup, syncDuo } from './duo-library';
import { type Asc, ascClient, EDITABLE, keptPrefix, md5, setUpToDate, type J } from './asc';
import { pendingScreenshots, repairPlan, repairSet, setRows, updateLedger } from './screenshot-health';
import { allVersions, parsePlatform, pickPlatform, platformLabel, stateOf, versionLabel } from './platform';
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
/** 13" iPad set, uploaded only when apple.ipad is true. */
export const IPAD_DISPLAY: Record<string, string> = { ipad13: 'APP_IPAD_PRO_3GEN_129' };
/** 6.3" iPhone (Dynamic Island, medium display), uploaded only when apple.iphoneMedium is true. */
export const MEDIUM_DISPLAY: Record<string, string> = { 'iphone-medium': 'APP_IPHONE_61' };
/** Mac App Store set (APP_DESKTOP, 16:10), uploaded to the macOS version only, with apple.mac. */
export const MAC_DISPLAY: Record<string, string> = { mac: 'APP_DESKTOP' };
/** Display sets this project uploads to a version of `platform`: macOS -> the Mac set (apple.mac); iOS (default) -> the
* iPhone sets, plus the 6.3" set with apple.iphoneMedium and the 13" iPad set with apple.ipad. */
export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iphoneDuo?: boolean; mac?: boolean } | undefined, platform: string = 'IOS'): Record<string, string> =>
platform === 'MAC_OS' ? (apple?.mac ? MAC_DISPLAY : {}) : ({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}),
...(apple?.iphoneDuo ? Object.fromEntries(Object.entries(IPHONE_DUO_DISPLAY).filter((e): e is [string, string] => !!e[1])) : {}) });
/** iPhone Duo size keys (render folders) `apple screenshots` uploads through the App Asset Library (no
* ScreenshotDisplayType exists for Duo: cli/apple/duo-library.ts, pitfall 130), outer display first. */
export const duoDirs = (apple: { iphoneDuo?: boolean } | undefined): string[] => (apple?.iphoneDuo ? Object.keys(IPHONE_DUO_DISPLAY) : []);
/** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's
* "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by
* scaling a 6.9" or 6.5" set down (ACCEPTED_INSTEAD), plus iPad 13" display with ipad. iPhone Duo is optional. */
export const requiredDisplayTypes = (apple: { ipad?: boolean; mac?: boolean; screenshotTypes?: string[] } | undefined, platform: string = 'IOS'): string[] =>
platform === 'MAC_OS' ? (apple?.mac ? Object.values(MAC_DISPLAY) : []) // a macOS version takes the Mac set only (apple.screenshotTypes is the iOS list)
: platform !== 'IOS' ? []
: apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
/** Makes one screenshot set (of a version localization, a custom product page localization or a Product Page
* Optimization treatment localization) hold exactly `files`, in order: left alone when the MD5s match, else the
* matching leading screenshots stay and the rest is replaced (c.syncAssets). The set is created when missing, after a
* fresh look (one set per display type: a 409 finds the existing one). */
export async function syncScreenshotSet(c: Asc, parent: { rel: string; type: string; id: string }, sets: J[], type: string, files: string[], tag: string, dry: boolean): Promise<void> {
let set = sets.find(s => s.attributes.screenshotDisplayType === type);
const existing = set ? await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`) : [];
// a file still exactly as `art pull` saved it is the store's own: compare the store's checksum of the original
const want = files.map(f => pulledChecksum(f, 'apple') ?? md5(readFileSync(f)));
const state = (s: J) => s.attributes.assetDeliveryState?.state;
if (set && setUpToDate(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: state(s) })), want)) { log('apple', `${tag}: ${files.length} up to date`); return; }
if (dry) {
const k = keptPrefix(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: state(s) })), want);
log('apple', `${tag}: would ${set ? `keep ${k}, replace ${existing.length - k}` : 'create the set and upload'} with ${files.length - k} new`);
return;
}
if (!set) {
const find = async () => (await c.all(`/v1/${parent.type}/${parent.id}/appScreenshotSets?limit=50`)).find((s: J) => s.attributes.screenshotDisplayType === type) ?? null;
const r = await findOrCreate<J>({ what: `${tag} screenshot set`, tag: 'apple', unique: true, find,
create: async () => (await c.asc('POST', '/v1/appScreenshotSets', { data: { type: 'appScreenshotSets', attributes: { screenshotDisplayType: type },
relationships: { [parent.rel]: { data: { type: parent.type, id: parent.id } } } } })).data });
set = r.value;
// a set found now (made by an earlier run the first read missed) may already hold screenshots
if (!r.created) existing.push(...await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`));
}
const r = await c.syncAssets({ set: { type: 'appScreenshotSets', id: set.id }, existing, files, want, state });
log('apple', `${tag}: uploaded ${r.uploaded}${r.kept ? `, kept ${r.kept}` : ''} (removed ${r.removed})`);
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants);
if (ctx.args.get('variant')) log('apple', `variant ${ctx.args.get('variant')}: ${src}`);
const only = ctx.args.list('only');
const jobs = ctx.args.num('jobs', 4);
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
// the platform: --platform, else the one with an editable version (both: asks)
const platform = parsePlatform(ctx.args.get('platform')) ?? pickPlatform(await allVersions(c, await c.app()), x => EDITABLE.has(stateOf(x)), undefined, 'an editable version');
const version = await c.editableVersion(platform);
if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (run apple listing --version x.y.z first)`);
log('apple', `screenshots for version ${versionLabel(version)}`);
let duo = platform === 'MAC_OS' ? [] : duoDirs(config.apple);
// a Duo problem never stops the other sets
const duoGroupId = duo.length ? await duoGroup(c).catch((e: Error) => { warn('apple', `iPhone Duo skipped: ${e.message}`); duo = []; return null; }) : null;
const appId = duo.length ? await c.app() : '';
if (platform === 'MAC_OS' && !config.apple?.mac) throw new Error('macOS version: set apple.mac: true and render the mac size (storebound shots render --size mac) first');
if (platform !== 'MAC_OS' && config.apple?.mac) log('apple', 'Mac screenshots (mac/, APP_DESKTOP) go to the macOS version: run apple screenshots --platform macos');
const langOf = byAppleLocale(config.languages);
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
const noLoc = missingVersionLocales(config.languages, locs.map((l: J) => l.attributes.locale as string)).filter(l => !only.length || only.includes(l));
for (const l of noLoc) warn('apple', `${l}: in the config languages but the version has no localization (run apple listing first)`);
const syncSet = (locId: string, sets: J[], type: string, files: string[], tag: string) =>
syncScreenshotSet(c, { rel: 'appStoreVersionLocalization', type: 'appStoreVersionLocalizations', id: locId }, sets, type, files, tag, ctx.args.has('dry'));
if (ctx.args.has('repair-stuck')) { await repairStuck(ctx, c, root, locs, src, platform, only); return; }
const tasks: (() => Promise<void>)[] = [];
for (const loc of locs) {
const locale: string = loc.attributes.locale;
if (only.length && !only.includes(locale)) continue;
const lang = langOf.get(locale);
if (!lang) { warn('apple', `${locale}: no language with apple: "${locale}", skipped`); continue; }
tasks.push(async () => {
const sets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`);
for (const [dir, type] of Object.entries(ctx.args.has('duo-only') ? {} : displaySets(config.apple, platform))) {
const d = join(src, lang.id, dir);
const files = numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10);
if (!existsSync(d) || !files.length) { warn('apple', `${locale}: nothing in ${d}, skipped`); continue; }
await syncSet(loc.id, sets, type, files, `${locale} ${type}`);
}
if (duo.length && duoGroupId) {
const files = duo.flatMap(dir => numberedImages(join(src, lang.id, dir)).filter(f => f.endsWith('.png')));
if (!files.length) warn('apple', `${locale}: nothing in ${duo.map(d => join(src, lang.id, d)).join(' or ')}, iPhone Duo skipped`);
else await syncDuo(c, { appId, group: duoGroupId.id, limit: duoGroupId.limit, loc, files, dry: ctx.args.has('dry') }).catch((e: Error) => { warn('apple', `${locale} iPhone Duo: ${e.message}`); process.exitCode = 1; });
}
});
}
log('apple', `${tasks.length} locales, ${jobs} in parallel`);
let next = 0;
await Promise.all(Array.from({ length: jobs }, async () => { while (next < tasks.length) await tasks[next++]!(); }));
let bad = 0;
for (const loc of locs) for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`))
for (const s of await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots`)) {
const st = s.attributes.assetDeliveryState;
if (st?.state === 'FAILED') { bad++; warn('apple', `${loc.attributes.locale} ${set.attributes.screenshotDisplayType} ${s.attributes.fileName}: ${JSON.stringify(st.errors)}`); }
}
done('apple', bad ? `${bad} screenshots failed processing (re-run to retry)` : 'screenshots done');
}
/** --repair-stuck: only the screenshots that are not COMPLETE and will not finish on their own (UPLOAD_COMPLETE for
* over 5 min, FAILED, or a stranded AWAITING_UPLOAD reservation) are replaced at their own positions with the same
* local file (same name in the renders). Everything else stays; still-processing ones are left to finish. --dry lists
* the plan. Re-runnable: a run cut midway leaves the rest for the next run, which skips what is already COMPLETE. */
async function repairStuck(ctx: Ctx, c: Asc, root: string, locs: J[], src: string, platform: string, only: string[]): Promise<void> {
const dry = ctx.args.has('dry');
const langOf = byAppleLocale(ctx.loaded.config.languages);
const dirOf = Object.fromEntries(Object.entries(displaySets(ctx.loaded.config.apple, platform)).map(([d, t]) => [t, d] as const));
let total = 0, held = 0;
for (const loc of locs) {
const locale: string = loc.attributes.locale;
if (only.length && !only.includes(locale)) continue;
for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets?limit=50`)) {
const type: string = set.attributes.screenshotDisplayType;
const rows = setRows(await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`), locale, type);
const pending = pendingScreenshots(rows, updateLedger(root, rows), Date.now());
held += pending.filter(p => p.kind === 'processing').length;
const lang = langOf.get(locale);
const dir = dirOf[type];
const files = lang && dir ? numberedImages(join(src, lang.id, dir)).filter(f => f.endsWith('.png')) : [];
const steps = repairPlan(rows, pending, files);
if (!steps.length) continue;
total += steps.length;
const tag = `${locale} ${type}`;
if (dry) { for (const st of steps) log('apple', `${tag}: would replace #${st.position} ${st.fileName} (${st.kind}) with ${st.file ?? 'NO LOCAL FILE'}`); continue; }
await repairSet(c, set.id, steps, m => log('apple', `${tag} ${m}`));
}
}
if (held) log('apple', `${held} screenshot(s) still processing (under 5 min): left alone; run validate again in a few minutes`);
done('apple', total ? `${dry ? 'dry run: would replace' : 'replaced'} ${total} stuck screenshot(s)` : 'no stuck screenshots');
}
/** App Store locales of the config languages that the version has no localization for. Pure. */
export const missingVersionLocales = (langs: { apple?: string }[], have: string[]): string[] =>
[...new Set(langs.flatMap(l => (l.apple ? [l.apple] : [])))].filter(l => !have.includes(l));