storebound/cli/apple/iap-review.ts

104 lines
7.9 KiB
TypeScript

// 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/<product id>.(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';
import { productStateText } from '../lib/review-submit';
import * as rt from '../lib/rt';
/** 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<void> {
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 rt.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)
const all = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
const approvedTypes = new Set(all.filter((x: { attributes: { state: string } }) => /APPROVED|REMOVED_FROM_SALE/.test(x.attributes.state)).map((x: { attributes: { inAppPurchaseType: string } }) => x.attributes.inAppPurchaseType));
for (const p of all) {
const state: string = p.attributes.state;
if (state !== 'MISSING_METADATA') { log('apple', `${p.attributes.productId}: ${productStateText(state, approvedTypes.has(p.attributes.inAppPurchaseType))}`); 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 = ready, not submitted yet; the first in-app purchase of a type goes to review with the next app version: storebound apple submit adds it)');
}