apple screenshots: iPhone Duo through the App Asset Library (IPHONE_DUO placement group, APP_SCREENSHOT), pitfall 130
This commit is contained in:
parent
b1043f5d79
commit
73a962fbdc
10 changed files with 174 additions and 32 deletions
113
cli/apple/duo-library.ts
Normal file
113
cli/apple/duo-library.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// iPhone Duo screenshots through the App Asset Library (App Store Connect API 4.5.1, pitfall 130). ScreenshotDisplayType
|
||||
// has no Duo value and never will: Apple added the display class IPHONE_DUO only to AppAssetLibraryDisplayClass and
|
||||
// deprecated appScreenshotSets / appScreenshots in the same release. Flow:
|
||||
// 1. GET /v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT -> the iOS placementProfileGroup whose
|
||||
// displayClassId is IPHONE_DUO (its id is the placementGroup of every Duo screenshot);
|
||||
// 2. each file is uploaded once to the app's library (POST appAssetLibraryImages, category
|
||||
// APP_SCREENSHOTS_AND_PREVIEWS, referenceName "storebound <md5>", the same MD5 reuse as creative assets);
|
||||
// 3. POST appAssetLibraryPlacements (placementType APP_SCREENSHOT, placementGroup, the version localization).
|
||||
// Screenshots are reviewed with the version, so a processed asset is placed at once (no approval wait).
|
||||
// Upsert: a group whose placements already show the local files in order is left alone; otherwise its placements are
|
||||
// removed and the files placed in order (outer display files first, then inner; at most 10). Read back at the end.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import { log, warn } from '../lib/log';
|
||||
import { elapsed } from '../lib/progress';
|
||||
import { classify, pickAsset, processingTiming, REF, selectable } from './creative-assets';
|
||||
import { type Asc, md5, type J } from './asc';
|
||||
import { partHeaders } from './screenshot-health';
|
||||
|
||||
const TAG = 'apple';
|
||||
export const DUO_CATEGORY = 'APP_SCREENSHOTS_AND_PREVIEWS';
|
||||
export const DUO_MAX = 10;
|
||||
const sumOf = (a: J | undefined): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
|
||||
|
||||
/** iOS placement profile group ids of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */
|
||||
export function duoGroups(refData: J[]): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const r of refData) for (const g of (r?.attributes?.placementProfileGroups ?? []) as J[])
|
||||
if (g?.displayClassId === 'IPHONE_DUO' && (!g.platform || g.platform === 'IOS') && g.placementProfileGroupId) ids.add(String(g.placementProfileGroupId));
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Whether a group's current placements (asset MD5s in order) already show `want`. Pure. */
|
||||
export const duoUpToDate = (shown: (string | undefined)[], want: string[]): boolean => shown.length === want.length && shown.every((s, i) => s === want[i]);
|
||||
|
||||
export interface DuoResult { locale: string; action: 'keep' | 'place' | 'wait' | 'refuse' | 'plan'; files: number }
|
||||
|
||||
/** Resolves the Duo placement group once per run; throws with the guardrail text when the API lists none. */
|
||||
export async function duoGroup(c: Asc): Promise<string> {
|
||||
const ref = await c.all('/v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT');
|
||||
const g = duoGroups(ref);
|
||||
if (!g.length) throw new Error('appAssetLibraryRefData lists no iOS IPHONE_DUO placement group for APP_SCREENSHOT: report it (pitfall 130); the console version page > App Previews and Screenshots > iPhone Duo takes them meanwhile');
|
||||
if (g.length > 1) log(TAG, `iPhone Duo: ${g.length} placement groups (${g.join(', ')}), using ${g[0]}`);
|
||||
return g[0]!;
|
||||
}
|
||||
|
||||
/** Makes the iPhone Duo screenshots of one version localization show `files` in order. */
|
||||
export async function syncDuo(c: Asc, o: { appId: string; group: string; loc: J; files: string[]; dry: boolean }): Promise<DuoResult> {
|
||||
const locale: string = o.loc.attributes.locale;
|
||||
const tag = `${locale} IPHONE_DUO`;
|
||||
const files = o.files.slice(0, DUO_MAX);
|
||||
if (o.files.length > DUO_MAX) warn(TAG, `${tag}: ${o.files.length} files, only the first ${DUO_MAX} are used`);
|
||||
const lib = (await c.asc('GET', `/v1/apps/${o.appId}/assetLibrary`)).data as J;
|
||||
const library = () => c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=${DUO_CATEGORY}&limit=200`);
|
||||
let assets = await library();
|
||||
const want = files.map(f => md5(readFileSync(f)));
|
||||
const placedAll = await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`);
|
||||
const placed = placedAll.filter(p => p.attributes?.placementGroup === o.group);
|
||||
const shown = placed.map(p => sumOf(assets.find(a => a.id === p.relationships?.image?.data?.id)));
|
||||
if (duoUpToDate(shown, want)) { log(TAG, `${tag}: ${files.length} up to date`); return { locale, action: 'keep', files: files.length }; }
|
||||
if (o.dry) {
|
||||
const up = want.filter(s => !pickAsset(assets, s).asset).length;
|
||||
log(TAG, `${tag}: would ${placed.length ? `replace ${placed.length} placed` : 'place'} ${files.length} screenshot(s) through the Asset Library (${up} to upload)`);
|
||||
return { locale, action: 'plan', files: files.length };
|
||||
}
|
||||
const chosen: J[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i]!, sum = want[i]!;
|
||||
const pick = pickAsset(assets, sum);
|
||||
if (pick.refuse) { warn(TAG, `${tag} ${basename(f)}: ${pick.refuse}`); return { locale, action: 'refuse', files: files.length }; }
|
||||
let a = pick.asset;
|
||||
if (!a) { log(TAG, `${tag}: uploading ${basename(f)} to the Asset Library`); a = await uploadImage(c, lib.id, f, sum); assets = await library(); a = pickAsset(assets, sum).asset ?? a; }
|
||||
else if (pick.unarchive) a = (await c.asc('PATCH', `/v1/${a.type}/${a.id}`, { data: { type: a.type, id: a.id, attributes: { archived: false } } })).data as J;
|
||||
a = await processed(c, a, `${tag} ${basename(f)}`);
|
||||
const cls = classify(a.attributes?.state);
|
||||
if (cls === 'processing') return { locale, action: 'wait', files: files.length };
|
||||
if (!selectable(cls)) { warn(TAG, `${tag} ${basename(f)}: ${a.attributes?.state}; nothing changed`); return { locale, action: 'refuse', files: files.length }; }
|
||||
chosen.push(a);
|
||||
}
|
||||
// every file is ready before the old placements go, so a failed upload leaves the page as it was
|
||||
for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`);
|
||||
for (const a of chosen) await c.asc('POST', '/v1/appAssetLibraryPlacements', { data: { type: 'appAssetLibraryPlacements', attributes: { placementType: 'APP_SCREENSHOT', placementGroup: o.group },
|
||||
relationships: { image: { data: { type: 'appAssetLibraryImages', id: a.id } }, appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: o.loc.id } } } } });
|
||||
const now = (await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`)).filter(p => p.attributes?.placementGroup === o.group);
|
||||
const ids = now.map(p => p.relationships?.image?.data?.id);
|
||||
if (ids.length !== chosen.length || chosen.some(a => !ids.includes(a.id))) { warn(TAG, `${tag}: read back ${ids.length} placements, wanted ${chosen.length}`); process.exitCode = 1; }
|
||||
else log(TAG, `${tag}: placed ${chosen.length} (removed ${placed.length})`);
|
||||
return { locale, action: 'place', files: chosen.length };
|
||||
}
|
||||
|
||||
async function uploadImage(c: Asc, libId: string, file: string, sum: string): Promise<J> {
|
||||
const buf = readFileSync(file);
|
||||
const type = 'appAssetLibraryImages';
|
||||
const res = (await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: basename(file), fileSize: buf.length, category: DUO_CATEGORY, referenceName: REF(sum) },
|
||||
relationships: { assetLibrary: { data: { type: 'appAssetLibraries', id: libId } } } } })).data as J;
|
||||
for (const op of (res.attributes.uploadOperations ?? []) as J[]) {
|
||||
const r = await fetch(op.url, { method: op.method, headers: partHeaders(op), body: buf.subarray(op.offset, op.offset + op.length), signal: AbortSignal.timeout(300_000) });
|
||||
if (r.status < 200 || r.status > 299) { await c.asc('DELETE', `/v1/${type}/${res.id}`).catch(() => {}); throw new Error(`upload ${basename(file)} part ${op.offset} -> ${r.status} (reservation deleted, nothing committed)`); }
|
||||
}
|
||||
return (await c.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true } } })).data as J;
|
||||
}
|
||||
|
||||
async function processed(c: Asc, a: J, label: string): Promise<J> {
|
||||
const t0 = Date.now();
|
||||
let beat = t0, cur = a;
|
||||
while (classify(cur.attributes?.state) === 'processing') {
|
||||
if (Date.now() - t0 >= processingTiming.timeoutMs) { warn(TAG, `${label}: still ${cur.attributes?.state ?? 'processing'} after ${elapsed(Date.now() - t0)}; re-run later to place it`); return cur; }
|
||||
await processingTiming.sleep(processingTiming.pollMs);
|
||||
cur = ((await c.maybe(`/v1/${cur.type}/${cur.id}`))?.data as J) ?? cur;
|
||||
if (Date.now() - beat >= processingTiming.heartbeatMs) { beat = Date.now(); log(TAG, `${label}: processing (${cur.attributes?.state}, ${elapsed(Date.now() - t0)})`); }
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
// 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 sets are skipped with the console deep link (no API display type yet). Up to 10
|
||||
// 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 <id> uploads <renders>/<id>/<lang>/... 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.
|
||||
|
|
@ -20,7 +20,8 @@ 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, IPHONE_DUO_NOT_IN_API } from '../shots/spec';
|
||||
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';
|
||||
|
|
@ -42,12 +43,9 @@ export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iph
|
|||
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 `apple screenshots` skips (apple.iphoneDuo on, no API display type yet), with the message. */
|
||||
export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } | undefined): { dirs: string[]; msg: string } | null {
|
||||
const dirs = apple?.iphoneDuo ? Object.entries(IPHONE_DUO_DISPLAY).filter(([, t]) => !t).map(([k]) => k) : [];
|
||||
// the console takes them (its own "iPhone Duo" tab): send the owner straight to the version page
|
||||
return dirs.length ? { dirs, msg: `${dirs.join(', ')}: ${IPHONE_DUO_NOT_IN_API}. Add them in ${consoleLink('asc.version', '> App Previews and Screenshots > iPhone Duo', { appleAppId: apple?.appId })}` } : null;
|
||||
}
|
||||
/** 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
|
||||
|
|
@ -98,8 +96,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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)}`);
|
||||
const duoSkip = platform === 'MAC_OS' ? null : skippedDuoSets(config.apple);
|
||||
if (duoSkip) warn('apple', duoSkip.msg);
|
||||
const duo = platform === 'MAC_OS' ? [] : duoDirs(config.apple);
|
||||
const duoGroupId = duo.length ? await duoGroup(c) : '';
|
||||
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);
|
||||
|
|
@ -127,6 +126,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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) {
|
||||
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, loc, files, dry: ctx.args.has('dry') });
|
||||
}
|
||||
});
|
||||
}
|
||||
log('apple', `${tasks.length} locales, ${jobs} in parallel`);
|
||||
|
|
|
|||
|
|
@ -700,8 +700,8 @@ export interface ShipConfig {
|
|||
*/
|
||||
iphoneMedium?: boolean;
|
||||
/** Also render and check iPhone Duo (foldable) screenshots: outer 1398 x 2034 and inner 2007 x 2853 (landscape too).
|
||||
* Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. Not uploaded while the API has no
|
||||
* display type for them (cli/shots/spec.ts IPHONE_DUO_DISPLAY). */
|
||||
* Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. `apple screenshots` uploads them through
|
||||
* the App Asset Library (display class IPHONE_DUO; there is no Duo ScreenshotDisplayType). */
|
||||
iphoneDuo?: boolean;
|
||||
/** Also render, check and upload Mac App Store screenshots (size key `mac`, 2880 x 1800, 16:10; Apple also takes
|
||||
* 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from <frames>/<lang>/landscape/ (shots.landscape). Uploaded
|
||||
|
|
|
|||
|
|
@ -61,11 +61,10 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
|
|||
feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false },
|
||||
};
|
||||
|
||||
/** ASC screenshotDisplayType per iPhone Duo size key. null = NOT IN THE API YET: ScreenshotDisplayType in App Store
|
||||
* Connect API 4.5.1 (spec re-checked 2026-10-20: the published zip is still the 2026-10-06 one, same sha256) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO).
|
||||
* When Apple adds it: regenerate (bun tools/gen-api.ts) and put the exact enum names here; uploads then include them. */
|
||||
/** iPhone Duo size keys. Values stay null: ScreenshotDisplayType has no Duo value and never will (API 4.5.1 put the
|
||||
* display class IPHONE_DUO into the App Asset Library only and deprecated screenshot sets); apple screenshots
|
||||
* uploads Duo through the library (cli/apple/duo-library.ts, pitfall 130). */
|
||||
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
|
||||
export const IPHONE_DUO_NOT_IN_API = 'not uploaded: the App Store Connect API has no iPhone Duo display type yet (the console takes them); the renders stay in store/renders';
|
||||
/** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */
|
||||
export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01';
|
||||
const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)';
|
||||
|
|
@ -75,7 +74,7 @@ const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" /
|
|||
export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean; today?: Date }): { level: 'info' | 'warn'; msg: string } | null {
|
||||
if (!o.apple || o.duo) return null;
|
||||
const late = (o.today ?? new Date()).toISOString().slice(0, 10) >= IPHONE_DUO_REQUIRED_FROM;
|
||||
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; the App Store Connect console takes Duo screenshots and previews, the API does not yet (apple screenshots skips them with the console link)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
|
||||
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; apple screenshots uploads them through the App Asset Library (display class IPHONE_DUO)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
|
||||
}
|
||||
|
||||
/** validate / check note for an app with a macOS version but no Mac screenshots (apple.mac off or no mac renders). */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue