// Screenshots that hold up a submission: App Store Connect answers 409 STATE_ERROR.ASSET_IN_POST_PROCESSING.APP_SCREENSHOT // to a submit while any screenshot of the version is not COMPLETE. Seen live: a part upload answered with a non-2xx // behind `Expect: 100-continue` was still committed, and the screenshot then sat in UPLOAD_COMPLETE for hours (never // processed), blocking the submit. This module names every such screenshot (locale, display type, position, file), // tells stuck ones (UPLOAD_COMPLETE longer than STUCK_MS) from ones still processing, and replaces only those in // place (`apple screenshots --repair-stuck`). // // Apple gives no timestamp on a screenshot, so "how long in UPLOAD_COMPLETE" comes from a local ledger // (.storebound/screenshot-states.json: screenshot id -> first time this machine saw it UPLOAD_COMPLETE, or committed it). import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, dirname, join } from 'node:path'; import type { J } from './asc'; /** UPLOAD_COMPLETE longer than this is stuck, not processing (Apple normally finishes in well under a minute). */ export const STUCK_MS = 5 * 60_000; export interface ShotRow { id: string; locale: string; displayType: string; position: number; fileName: string; state: string; checksum?: string | null } export type PendingKind = 'processing' | 'stuck' | 'failed' | 'awaiting-upload'; export interface Pending extends ShotRow { kind: PendingKind; ageMs?: number } /** The submit / validate error this module explains. Pure. */ export const isScreenshotProcessing409 = (e: unknown): boolean => /-> 409\b/.test(String((e as Error)?.message ?? e)) && /ASSET_IN_POST_PROCESSING(\.APP_SCREENSHOT)?/.test(String((e as Error)?.message ?? e)); const ledgerFile = (root: string): string => join(root, '.storebound', 'screenshot-states.json'); export function readLedger(root: string): Record { try { return JSON.parse(readFileSync(ledgerFile(root), 'utf8')) as Record; } catch { return {}; } } /** Notes the first time each UPLOAD_COMPLETE screenshot of `rows` was seen (a known one keeps its time; one in another * state is dropped; screenshots not in `rows` are left as they are). */ export function updateLedger(root: string, rows: { id: string; state: string }[], now = Date.now()): Record { const next: Record = { ...readLedger(root) }; for (const r of rows) { if (r.state === 'UPLOAD_COMPLETE') next[r.id] = next[r.id] ?? now; else delete next[r.id]; } try { mkdirSync(dirname(ledgerFile(root)), { recursive: true }); writeFileSync(ledgerFile(root), JSON.stringify(next, null, 2) + '\n'); } catch { /* read-only project: ages stay unknown */ } return next; } /** Records a screenshot this run just committed (its UPLOAD_COMPLETE clock starts now). */ export function noteCommitted(root: string, id: string, now = Date.now()): void { const l = readLedger(root); l[id] = l[id] ?? now; try { mkdirSync(dirname(ledgerFile(root)), { recursive: true }); writeFileSync(ledgerFile(root), JSON.stringify(l, null, 2) + '\n'); } catch { /* ignore */ } } /** Every screenshot that is not COMPLETE, classified. A screenshot with no known first-seen time counts as processing * (the next look starts its clock). Pure. */ export function pendingScreenshots(rows: ShotRow[], seen: Record, now: number, stuckMs = STUCK_MS): Pending[] { const out: Pending[] = []; for (const r of rows) { if (r.state === 'COMPLETE') continue; if (r.state === 'FAILED') { out.push({ ...r, kind: 'failed' }); continue; } if (r.state === 'AWAITING_UPLOAD') { out.push({ ...r, kind: 'awaiting-upload' }); continue; } const at = seen[r.id]; const ageMs = at === undefined ? undefined : now - at; out.push({ ...r, kind: r.state === 'UPLOAD_COMPLETE' && ageMs !== undefined && ageMs > stuckMs ? 'stuck' : 'processing', ...(ageMs !== undefined ? { ageMs } : {}) }); } return out; } /** Can --repair-stuck replace it (stuck, failed or a stranded reservation)? Processing ones are left to finish. */ export const repairable = (p: Pending): boolean => p.kind !== 'processing'; const mins = (ms: number): string => `${Math.round(ms / 60_000)} min`; /** One line per pending screenshot. Pure. */ export function describePending(list: Pending[]): string[] { return list.map(p => `${p.locale} ${p.displayType} #${p.position} ${p.fileName}: ${p.state}${p.ageMs !== undefined ? ` for ${mins(p.ageMs)}` : ''} -> ${ p.kind === 'stuck' ? 'STUCK (not processing)' : p.kind === 'failed' ? 'FAILED' : p.kind === 'awaiting-upload' ? 'stranded reservation (never uploaded)' : 'still processing'}`); } /** The explanation for a 409 ASSET_IN_POST_PROCESSING: what holds the submit and what to do. Pure. */ export function explain409(list: Pending[]): string { if (!list.length) return 'App Store Connect says a screenshot is still processing, but every screenshot of the version now reads COMPLETE: run the same command again.'; const bad = list.filter(repairable); return `App Store Connect refuses the submit while ${list.length} screenshot(s) are not COMPLETE:\n${describePending(list).map(l => ' ' + l).join('\n')}\n` + (bad.length ? `${bad.length} will never finish on their own: storebound apple screenshots --repair-stuck --dry, then without --dry (replaces only these, at their positions), then submit again.` : `They are still processing (under ${mins(STUCK_MS)}): run the same command again in a few minutes.`); } /** Every screenshot of one version, with its locale, display type and 1-based position. */ export async function versionScreenshotRows(c: { all: (p: string) => Promise }, versionId: string): Promise { const rows: ShotRow[] = []; for (const loc of await c.all(`/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations?limit=50`)) for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets?limit=50`)) rows.push(...setRows(await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`), loc.attributes.locale, set.attributes.screenshotDisplayType)); return rows; } export const setRows = (shots: J[], locale: string, displayType: string): ShotRow[] => shots.map((s, i) => ({ id: s.id, locale, displayType, position: i + 1, fileName: s.attributes?.fileName ?? '?', state: s.attributes?.assetDeliveryState?.state ?? 'UNKNOWN', checksum: s.attributes?.sourceFileChecksum, })); /** Request headers of one upload part, without `Expect` (100-continue): seen live, a part sent with it got a non-2xx * that looked like progress and the asset never processed. Pure. */ export function partHeaders(op: { requestHeaders?: { name: string; value: string }[] }): Record { const h: Record = {}; for (const x of op.requestHeaders ?? []) if (x.name.toLowerCase() !== 'expect') h[x.name] = x.value; return h; } export interface RepairStep { id: string; position: number; fileName: string; file: string | null; kind: PendingKind } /** What --repair-stuck does in one set: each repairable screenshot with the local file of the same name (else the file * at its position). Pure. */ export function repairPlan(shots: ShotRow[], pending: Pending[], files: string[]): RepairStep[] { const ids = new Set(pending.filter(repairable).map(p => p.id)); return shots.filter(s => ids.has(s.id)).map(s => ({ id: s.id, position: s.position, fileName: s.fileName, kind: pending.find(p => p.id === s.id)!.kind, file: files.find(f => basename(f) === s.fileName) ?? files[s.position - 1] ?? null, })); } export interface RepairClient { asc: (method: string, path: string, body?: unknown) => Promise; all: (path: string) => Promise; uploadAsset: (type: string, rel: Record, file: string) => Promise; } /** Replaces the planned screenshots of one set at their own positions: delete, upload the same file, put the order * back, then read the set again and check the count did not change. Re-runnable: a step whose screenshot is gone * already is skipped, and a set that still holds the same count afterwards is left as it is. */ export async function repairSet(c: RepairClient, setId: string, steps: RepairStep[], say: (m: string) => void = () => {}): Promise<{ replaced: number }> { const before = (await c.all(`/v1/appScreenshotSets/${setId}/appScreenshots?limit=50`)).map(x => x.id as string); const order = [...before]; let replaced = 0; for (const s of steps) { const at = order.indexOf(s.id); if (at < 0) continue; // replaced by an earlier run if (!s.file || !existsSync(s.file)) throw new Error(`#${s.position} ${s.fileName}: no local file to re-upload (looked for the same name in the renders); nothing changed for it`); await c.asc('DELETE', `/v1/appScreenshots/${s.id}`); const id = await c.uploadAsset('appScreenshots', { appScreenshotSet: { data: { type: 'appScreenshotSets', id: setId } } }, s.file); order[at] = id; replaced++; say(`#${s.position} ${s.fileName}: replaced (${s.kind})`); } if (replaced) { const now = (await c.all(`/v1/appScreenshotSets/${setId}/appScreenshots?limit=50`)).map(x => x.id as string); if (now.join() !== order.join() && order.every(id => now.includes(id))) await c.asc('PATCH', `/v1/appScreenshotSets/${setId}/relationships/appScreenshots`, { data: order.map(id => ({ type: 'appScreenshots', id })) }); const after = (await c.all(`/v1/appScreenshotSets/${setId}/appScreenshots?limit=50`)).map(x => x.id as string); if (after.length !== before.length) throw new Error(`set ${setId}: ${before.length} screenshots before the repair, ${after.length} after: re-run storebound apple screenshots to restore the set`); if (after.join() !== order.join()) throw new Error(`set ${setId}: order after the repair differs from the original positions: re-run storebound apple screenshots`); } return { replaced }; }