// 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: //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 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 { 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 = { 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' }; /** Mac App Store set (APP_DESKTOP, 16:10), uploaded to the macOS version only, with apple.mac. */ export const MAC_DISPLAY: Record = { 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 => 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 { 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); // 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)[] = []; 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 { 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));