// Upload the App Review screenshot of every in-app purchase (idempotent: a screenshot whose MD5 matches and that // Apple processed is kept) and print each product's state with what is still missing. Nothing is submitted. // storebound apple iap-review [--dry] // Screenshot per product: products[].reviewScreenshot, else apple.reviewShots/.(png|jpg). // Size: an App Store screenshot size (iPhone or iPad, e.g. 1320 x 2868 or 1290 x 2796); Apple processes the upload // after it is committed and fails other sizes with IMAGE_INCORRECT_DIMENSIONS, so the size is checked before the // upload and the delivery state is read back after it (an upload is only "done" when Apple says COMPLETE). // Config products missing from the store's list (it lags a minute after `apple iap` creates them) are named with a // warning and the exit code is 1, so nothing is skipped silently (docs/pitfalls.md). --dry ends with the plan table. import { existsSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { sellsOn } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { dryPlan, type PlanRow } from '../lib/dry'; import { done, log, warn } from '../lib/log'; import { reportMissing } from '../lib/store-lag'; import { IMAGE_SPECS, imageInfo } from '../shots/spec'; import { ascClient, md5 } from './asc'; /** Every iPhone / iPad screenshot size App Store Connect accepts (portrait and landscape). */ export const REVIEW_SHOT_SIZES: [number, number][] = Object.values(IMAGE_SPECS) .filter(s => s.store === 'apple' && /^APP_(IPHONE|IPAD)/.test(s.type)) .flatMap(s => s.sizes.flatMap(([w, h]) => [[w, h], [h, w]] as [number, number][])); /** null when the image can be used as an App Review screenshot, else why not. Pure. */ export function reviewShotProblem(buf: Uint8Array): string | null { const i = imageInfo(buf); if (i.format === 'unknown') return 'not a PNG or JPEG'; if (!REVIEW_SHOT_SIZES.some(([w, h]) => w === i.w && h === i.h)) return `${i.w} x ${i.h} is not an App Store screenshot size (use e.g. 1320 x 2868 or 1290 x 2796 for iPhone, 2064 x 2752 for iPad; Apple fails other sizes with IMAGE_INCORRECT_DIMENSIONS)`; return null; } type Shot = { id: string; attributes: { sourceFileChecksum?: string; fileName?: string; assetDeliveryState?: { state?: string; errors?: { code?: string; description?: string }[] } } }; const stateOf = (s: Shot | undefined): string => s?.attributes.assetDeliveryState?.state ?? 'NONE'; const errorsOf = (s: Shot | undefined): string => (s?.attributes.assetDeliveryState?.errors ?? []).map(e => e.code ?? e.description).filter(Boolean).join(', '); export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const dry = ctx.args.has('dry'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); const defs = new Map((config.products ?? []).map(p => [p.id, p])); const shotOf = (id: string) => c.maybe(`/v2/inAppPurchases/${id}/appStoreReviewScreenshot`).then(r => r?.data as Shot | undefined); const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`); const want = (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id); const missing = reportMissing('apple', 'products', want, iaps.map(p => p.attributes.productId as string), { fail: true }); const rows: PlanRow[] = missing.map(id => ({ item: id, action: 'skip', detail: 'not in the store\'s list yet (run apple iap first, or re-run in a minute)' })); let problems = 0; for (const p of iaps) { const sku: string = p.attributes.productId; const def = defs.get(sku); const file = def?.reviewScreenshot ? resolve(def.reviewScreenshot) : ['png', 'jpg', 'jpeg'].map(e => resolve(join(config.apple?.reviewShots ?? 'store/iap-review', `${sku}.${e}`))).find(existsSync); if (!file || !existsSync(file)) { warn('apple', `${sku}: no review screenshot (set products[].reviewScreenshot)`); rows.push({ item: sku, action: 'skip', detail: 'no review screenshot' }); problems++; continue; } const buf = readFileSync(file); const bad = reviewShotProblem(buf); const cur = await shotOf(p.id); const curState = stateOf(cur); if (bad) { warn('apple', `${sku}: ${relative(root, file)}: ${bad}${cur ? `; App Store Connect has ${curState}${errorsOf(cur) ? ` (${errorsOf(cur)})` : ''}` : ''}`); rows.push({ item: sku, action: 'skip', detail: bad }); problems++; continue; } if (cur && cur.attributes.sourceFileChecksum === md5(buf) && curState !== 'FAILED') { log('apple', `${sku}: screenshot up to date (${curState})`); rows.push({ item: sku, action: 'keep' }); continue; } const why = !cur ? 'none in App Store Connect' : curState === 'FAILED' ? `the one there FAILED${errorsOf(cur) ? ` (${errorsOf(cur)})` : ''}` : 'a different file is there'; if (dry) { log('apple', `${sku}: would upload ${relative(root, file)} (${why})`); rows.push({ item: sku, action: cur ? 'replace' : 'upload', detail: `${relative(root, file)} (${why})` }); continue; } if (cur) await c.asc('DELETE', `/v1/inAppPurchaseAppStoreReviewScreenshots/${cur.id}`); await c.uploadAsset('inAppPurchaseAppStoreReviewScreenshots', { inAppPurchaseV2: { data: { type: 'inAppPurchases', id: p.id } } }, file); // Apple processes the asset after the commit: read the state back (COMPLETE, or FAILED with the reason) let after: Shot | undefined, state = 'UPLOAD_COMPLETE'; for (let i = 0; i < 10; i++) { await Bun.sleep(i ? 3000 : 1500); after = await shotOf(p.id); state = stateOf(after); if (state === 'COMPLETE' || state === 'FAILED') break; } if (state === 'FAILED') { warn('apple', `${sku}: Apple rejected the screenshot: ${errorsOf(after) || 'FAILED'}`); problems++; } else log('apple', `${sku}: screenshot uploaded (${state})`); } // what keeps a product in MISSING_METADATA (Apple's rule: a localization, a price, availability and the screenshot) for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) { const state: string = p.attributes.state; if (state !== 'MISSING_METADATA') { log('apple', `${p.attributes.productId}: ${state}`); continue; } const lack: string[] = []; const locs = await c.maybe(`/v2/inAppPurchases/${p.id}/inAppPurchaseLocalizations?limit=50`); if (!locs?.data?.length) lack.push('a localization (name + description)'); const price = await c.maybe(`/v2/inAppPurchases/${p.id}/iapPriceSchedule`); if (!price?.data) lack.push('a price'); const avail = await c.maybe(`/v2/inAppPurchases/${p.id}/inAppPurchaseAvailability`); if (!avail?.data) lack.push('availability (territories)'); const shot = await shotOf(p.id); if (!shot) lack.push('the review screenshot'); else if (stateOf(shot) === 'FAILED') lack.push(`a working review screenshot (FAILED: ${errorsOf(shot) || 'unknown'})`); else if (stateOf(shot) !== 'COMPLETE') lack.push(`Apple to finish processing the screenshot (${stateOf(shot)}; check again in a minute)`); log('apple', `${p.attributes.productId}: MISSING_METADATA${lack.length ? `: needs ${lack.join(', ')}` : ' (all parts present: Apple may still be updating the state)'}`); } if (dry) console.log(dryPlan('apple iap-review', rows)); if (problems) process.exitCode = 1; done('apple', dry ? 'iap-review dry run (nothing uploaded)' : 'iap-review done (READY_TO_SUBMIT = complete; first IAPs go to review with a version, from the IAP page)'); }