179 lines
14 KiB
TypeScript
179 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] [--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 sets are skipped with the console deep link (no API display type yet). 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, IPHONE_DUO_NOT_IN_API } from '../shots/spec';
|
|
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 `apple screenshots` skips (apple.iphoneDuo on, no API display type yet), with the message. */
|
|
export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } | undefined): { dirs: string[]; msg: string } | null {
|
|
const dirs = apple?.iphoneDuo ? Object.entries(IPHONE_DUO_DISPLAY).filter(([, t]) => !t).map(([k]) => k) : [];
|
|
// the console takes them (its own "iPhone Duo" tab): send the owner straight to the version page
|
|
return dirs.length ? { dirs, msg: `${dirs.join(', ')}: ${IPHONE_DUO_NOT_IN_API}. Add them in ${consoleLink('asc.version', '> App Previews and Screenshots > iPhone Duo', { appleAppId: apple?.appId })}` } : null;
|
|
}
|
|
|
|
/** 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)}`);
|
|
const duoSkip = platform === 'MAC_OS' ? null : skippedDuoSets(config.apple);
|
|
if (duoSkip) warn('apple', duoSkip.msg);
|
|
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(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}`);
|
|
}
|
|
});
|
|
}
|
|
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));
|