107 lines
7.5 KiB
TypeScript
107 lines
7.5 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]
|
|
// 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). 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 { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
|
|
|
|
/** 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' };
|
|
|
|
/** Display sets this project uploads: 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 } | undefined): Record<string, string> =>
|
|
({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}) });
|
|
|
|
/** Display types `validate` requires on every locale: apple.screenshotTypes, else 6.9" iPhone (+ 13" iPad with ipad). */
|
|
export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] =>
|
|
apple?.screenshotTypes ?? ['APP_IPHONE_67', ...(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`) : [];
|
|
const want = files.map(f => 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);
|
|
const version = await c.editableVersion();
|
|
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
|
|
const langOf = byAppleLocale(config.languages);
|
|
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
|
|
|
|
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'));
|
|
|
|
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))) {
|
|
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');
|
|
}
|