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
|
|
@ -3,6 +3,9 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- **iPhone Duo screenshots upload.** `apple screenshots` now sends them through the App Asset Library (display class
|
||||
IPHONE_DUO, placement APP_SCREENSHOT) instead of skipping them as "not in the API" (pitfall 130).
|
||||
|
||||
## 0.8.71 · 2026-11-07
|
||||
|
||||
- 0.8.69 and 0.8.70 were tagged but not published (the desktop release check stopped them); 0.8.71 ships both sections below. Desktop Portfolio: the Play state of an app uses the same words as Releases ("Not live yet: in review or blocked before review").
|
||||
|
|
|
|||
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). */
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ State of storebound for whoever picks it up next. Public-safe: no project, app o
|
|||
- The Play Games Services console page paths.
|
||||
- The Games Configuration API bodies (no pinned spec).
|
||||
- Placing a Header and Search Results asset on a live App Store version.
|
||||
- **iPhone Duo:** App Store Connect's API has no Duo screenshot type yet. Uploads skip with a console link; wire it
|
||||
when the spec gains the type (`IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts`).
|
||||
- **iPhone Duo:** uploaded through the App Asset Library (`cli/apple/duo-library.ts`); previews still use the deprecated sets.
|
||||
|
||||
## Rules that matter here
|
||||
|
||||
|
|
|
|||
|
|
@ -82,8 +82,7 @@ languages, every store size, a review gallery and an app preview video. No store
|
|||
Play takes a YouTube URL in `play.video`.
|
||||
7. **iPhone Duo (foldable).** `apple.iphoneDuo: true` renders and checks the outer (1398 x 2034) and inner
|
||||
(2007 x 2853) sets. Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds (`check` / `validate`
|
||||
note it). The API has no Duo display type yet, so `apple screenshots` skips them with a link to the console's
|
||||
iPhone Duo tab. Play has no foldable slot: foldables use the 7" / 10" tablet sets.
|
||||
note it). `apple screenshots` uploads them through the App Asset Library (display class IPHONE_DUO). Play has no foldable slot: foldables use the 7" / 10" tablet sets.
|
||||
|
||||
Details: the [store-screenshots](../skills/store-screenshots/SKILL.md) and [app-previews](../skills/app-previews/SKILL.md)
|
||||
skills.
|
||||
|
|
|
|||
|
|
@ -1610,3 +1610,19 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
- **Guard:** `cli/play/old-builds.ts` (play status, validate `old-builds`), `PLAY_NOT_LIVE` in `cli/lib/portfolio.ts`,
|
||||
`isAutoSendOnly` in `cli/play/review-pending.ts` (the commit is redone without changesNotSentForReview),
|
||||
`tests/play-old-builds.test.ts`.
|
||||
|
||||
## 130. iPhone Duo screenshots were skipped as "not in the API"
|
||||
|
||||
- **Symptom:** `apple screenshots` skipped the iPhone Duo sets with "the API has no iPhone Duo display type yet".
|
||||
Wrong: ScreenshotDisplayType never gets a Duo value. API 4.5.1 put the display class `IPHONE_DUO` into the App Asset
|
||||
Library (AppAssetLibraryDisplayClass) and deprecated appScreenshotSets / appScreenshots / appPreviewSets /
|
||||
appPreviews in the same release.
|
||||
- **Do:** `storebound apple screenshots` uploads Duo (apple.iphoneDuo, renders iphone-duo-outer/ then
|
||||
iphone-duo-inner/) through the library: `appAssetLibraryRefData` (filter placementTypes APP_SCREENSHOT) gives the
|
||||
iOS placementProfileGroup of IPHONE_DUO, files go up as appAssetLibraryImages (category
|
||||
APP_SCREENSHOTS_AND_PREVIEWS), then appAssetLibraryPlacements (APP_SCREENSHOT, that placementGroup, the version
|
||||
localization). Same MD5 reuse as creative assets; read back.
|
||||
- **Never:** say an API lacks a store feature after checking one enum: search the whole spec (every schema with the
|
||||
feature's name) first.
|
||||
- **Guard:** `cli/apple/duo-library.ts` (duoGroups, syncDuo), `duoDirs` in `cli/apple/screenshots.ts`,
|
||||
`tests/ppo.test.ts`. Previews and the other display classes still use the deprecated sets until they move too.
|
||||
|
|
|
|||
|
|
@ -71,10 +71,9 @@ in a plain Android tablet body instead of the iPad one.
|
|||
outer (1398 x 2034) and an inner (2007 x 2853) set, portrait or landscape, PNG / JPEG without alpha, 1-10 each. It is
|
||||
optional now and reportedly required from April 2027 for builds with the iOS 27.1 SDK or later; `check` / `validate`
|
||||
print an info note until then (a warning after, never a blocker). `apple.iphoneDuo: true` adds both sizes to
|
||||
`shots render` / `shots check` / the gallery. The API (4.5.1) has no Duo `ScreenshotDisplayType` yet, so
|
||||
`apple screenshots` skips the Duo folders with a message and the deep link to the version page; add them in the
|
||||
console's iPhone Duo tab until then (one constant, `IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts`, takes the names once
|
||||
the enum has them). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the
|
||||
`shots render` / `shots check` / the gallery. ScreenshotDisplayType has no Duo value (and won't get one), so
|
||||
`apple screenshots` uploads the Duo folders through the App Asset Library (display class IPHONE_DUO, placement
|
||||
APP_SCREENSHOT; pitfall 130). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the
|
||||
7" / 10" tablet sets below.
|
||||
|
||||
**Mac App Store.** Apple's Screenshot specifications (checked 2026-10-20) list four 16:10 Mac sizes: 1280 x 800,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { displaySets, skippedDuoSets } from '../cli/apple/screenshots';
|
||||
import { displaySets, duoDirs } from '../cli/apple/screenshots';
|
||||
import { duoGroups, duoUpToDate } from '../cli/apple/duo-library';
|
||||
import { appleRules } from '../cli/lib/validate-rules';
|
||||
import { checkCreate, createExperimentReq, defaultName, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq } from '../cli/lib/ppo';
|
||||
import { validateConfig } from '../cli/lib/config';
|
||||
import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
|
||||
import { requiredSets } from '../cli/shots/report';
|
||||
import { defaultSizes, SIZES } from '../cli/shots/lib';
|
||||
import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API, iphoneDuoNote } from '../cli/shots/spec';
|
||||
import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, iphoneDuoNote } from '../cli/shots/spec';
|
||||
import { png } from './fixtures';
|
||||
|
||||
describe('product page optimization', () => {
|
||||
|
|
@ -91,12 +92,21 @@ describe('iPhone Duo (foldable) sizes', () => {
|
|||
expect(checkImage(imageInfo(png(2007, 2853)), 'iphone-duo-outer').map(i => i.code)).toEqual(['size']);
|
||||
expect(checkImage(imageInfo(png(2007, 2853, { colorType: 6 })), 'iphone-duo-inner').map(i => i.code)).toEqual(['alpha']);
|
||||
});
|
||||
test('upload: no API display type yet, so the sets are skipped with the message', () => {
|
||||
test('upload: no ScreenshotDisplayType; Duo goes through the App Asset Library (IPHONE_DUO group)', () => {
|
||||
expect(Object.values(IPHONE_DUO_DISPLAY).every(t => t === null)).toBe(true);
|
||||
expect(Object.keys(displaySets({ iphoneDuo: true }))).not.toContain('iphone-duo-outer');
|
||||
expect(skippedDuoSets({})).toBeNull();
|
||||
expect(skippedDuoSets({ iphoneDuo: true })).toEqual({ dirs: ['iphone-duo-outer', 'iphone-duo-inner'], msg: expect.stringContaining(`iphone-duo-outer, iphone-duo-inner: ${IPHONE_DUO_NOT_IN_API}. Add them in App Store Connect > the iOS version page > App Previews and Screenshots > iPhone Duo: https://`) });
|
||||
expect(skippedDuoSets({ iphoneDuo: true, appId: '1234567890' })!.msg).toContain('/apps/1234567890/distribution/ios/version/inflight');
|
||||
expect(duoDirs({})).toEqual([]);
|
||||
expect(duoDirs({ iphoneDuo: true })).toEqual(['iphone-duo-outer', 'iphone-duo-inner']);
|
||||
const ref = [{ attributes: { placementProfileGroups: [
|
||||
{ placementProfileGroupId: 'g-67', platform: 'IOS', displayClassId: 'IPHONE_DYNAMIC_ISLAND_LARGE_DISPLAY' },
|
||||
{ placementProfileGroupId: 'g-duo', platform: 'IOS', displayClassId: 'IPHONE_DUO' },
|
||||
{ placementProfileGroupId: 'g-duo', platform: 'IOS', displayClassId: 'IPHONE_DUO' },
|
||||
{ placementProfileGroupId: 'g-mac', platform: 'MAC_OS', displayClassId: 'IPHONE_DUO' }] } }];
|
||||
expect(duoGroups(ref)).toEqual(['g-duo']);
|
||||
expect(duoGroups([])).toEqual([]);
|
||||
expect(duoUpToDate(['a', 'b'], ['a', 'b'])).toBe(true);
|
||||
expect(duoUpToDate(['b', 'a'], ['a', 'b'])).toBe(false);
|
||||
expect(duoUpToDate(['a', undefined], ['a', 'b'])).toBe(false);
|
||||
});
|
||||
test('check / validate note: info before April 2027, never a blocker; points Play to the tablet sets', () => {
|
||||
const before = new Date('2026-10-18'), after = new Date('2027-04-02');
|
||||
|
|
@ -106,7 +116,7 @@ describe('iPhone Duo (foldable) sizes', () => {
|
|||
expect(n.level).toBe('info');
|
||||
expect(n.msg).toContain('optional now, required from April 2027 for iOS 27.1 SDK builds');
|
||||
expect(n.msg).toContain('tablet screenshots');
|
||||
expect(n.msg).toContain('the App Store Connect console takes Duo screenshots and previews, the API does not yet');
|
||||
expect(n.msg).toContain('apple screenshots uploads them through the App Asset Library');
|
||||
expect(iphoneDuoNote({ apple: true, duo: false, today: after })!.level).toBe('warn');
|
||||
const base = { version: null, build: null, locales: [], expectedLocales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } };
|
||||
const f = appleRules({ ...base, today: before } as unknown as Parameters<typeof appleRules>[0]).find(x => x.code === 'screenshots-duo')!;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue