147 lines
9.8 KiB
TypeScript
147 lines
9.8 KiB
TypeScript
// 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<string, number> {
|
|
try { return JSON.parse(readFileSync(ledgerFile(root), 'utf8')) as Record<string, number>; } 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<string, number> {
|
|
const next: Record<string, number> = { ...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<string, number>, 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<J[]> }, versionId: string): Promise<ShotRow[]> {
|
|
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<string, string> {
|
|
const h: Record<string, string> = {};
|
|
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<J>;
|
|
all: (path: string) => Promise<J[]>;
|
|
uploadAsset: (type: string, rel: Record<string, unknown>, file: string) => Promise<string>;
|
|
}
|
|
|
|
/** 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 };
|
|
}
|