// 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: //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 uploads ///... 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 { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc'; /** Render size key -> ASC screenshotDisplayType (iPhone sets). */ export const DISPLAY: Record = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' }; /** 13" iPad set, uploaded only when apple.ipad is true. */ export const IPAD_DISPLAY: Record = { 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 = { '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 => ({ ...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 { 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({ 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 { 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)[] = []; 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'); }