From 73a962fbdc2ec576052290d3583f90a2d9ac2975 Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 8 Nov 2026 14:19:12 +0700 Subject: [PATCH] apple screenshots: iPhone Duo through the App Asset Library (IPHONE_DUO placement group, APP_SCREENSHOT), pitfall 130 --- CHANGELOG.md | 3 + cli/apple/duo-library.ts | 113 ++++++++++++++++++++++++++++++ cli/apple/screenshots.ts | 24 ++++--- cli/lib/config.ts | 4 +- cli/shots/spec.ts | 9 ++- docs/HANDOFF.md | 3 +- docs/store-art.md | 3 +- skills/store-pitfalls/SKILL.md | 16 +++++ skills/store-screenshots/SKILL.md | 7 +- tests/ppo.test.ts | 24 +++++-- 10 files changed, 174 insertions(+), 32 deletions(-) create mode 100644 cli/apple/duo-library.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2651ed5..8c13b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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"). diff --git a/cli/apple/duo-library.ts b/cli/apple/duo-library.ts new file mode 100644 index 0000000..86c620e --- /dev/null +++ b/cli/apple/duo-library.ts @@ -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 ", 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(); + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index bc7bfce..fc2adb6 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -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 uploads ///... 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 { 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 { 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`); diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 0afa907..74313a6 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -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 //landscape/ (shots.landscape). Uploaded diff --git a/cli/shots/spec.ts b/cli/shots/spec.ts index 40ae395..bd67387 100644 --- a/cli/shots/spec.ts +++ b/cli/shots/spec.ts @@ -61,11 +61,10 @@ export const IMAGE_SPECS: Record = { 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 = { '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). */ diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index a4a4abd..e3dbda5 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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 diff --git a/docs/store-art.md b/docs/store-art.md index 3ed9fba..35a7a05 100644 --- a/docs/store-art.md +++ b/docs/store-art.md @@ -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. diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 5110489..956e371 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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. diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index ad3d9f7..f0d9a5d 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -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, diff --git a/tests/ppo.test.ts b/tests/ppo.test.ts index f4c22c2..3770a30 100644 --- a/tests/ppo.test.ts +++ b/tests/ppo.test.ts @@ -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[0]).find(x => x.code === 'screenshots-duo')!;