From 108b362cd9efa78f18aca46f8e0e3f88210935b4 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 9 Nov 2026 06:35:00 +0700 Subject: [PATCH] iPhone Duo: limit from Apple's reference data instead of a cut to 10, --duo-only; shared asset library helpers in their own module --- CHANGELOG.md | 2 ++ cli/apple/asset-library.ts | 57 ++++++++++++++++++++++++++++++++++++ cli/apple/creative-assets.ts | 56 ++--------------------------------- cli/apple/duo-library.ts | 34 ++++++++++++++++----- cli/apple/screenshots.ts | 10 +++---- cli/data/flags.json | 13 ++++---- tests/ppo.test.ts | 5 +++- 7 files changed, 102 insertions(+), 75 deletions(-) create mode 100644 cli/apple/asset-library.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b4dab..5d1e2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased +- **iPhone Duo:** outer and inner files are no longer cut to the first 10: the limit comes from Apple's reference data, and over it nothing changes and nothing is dropped. `apple screenshots --duo-only` writes only the Duo set. `apple cpp` no longer lists creative-assets flags. + ## 0.8.76 ยท 2026-11-08 - 0.8.75 was tagged but not published (GitHub 502/504 in the Linux AppImage bundling). CI now takes the AppImage tools from a checked mirror instead of GitHub. diff --git a/cli/apple/asset-library.ts b/cli/apple/asset-library.ts new file mode 100644 index 0000000..a484865 --- /dev/null +++ b/cli/apple/asset-library.ts @@ -0,0 +1,57 @@ +// App Asset Library states and upsert rules shared by creative assets and iPhone Duo screenshots. No command flags here. +import * as rt from '../lib/rt'; +import type { J } from './asc'; + +export const REF = (sum: string): string => `storebound ${sum}`; +export const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1]; + +/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */ +export const ASSET_STATES = { + /** still uploading / processing: wait (bounded), then select */ + processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'], + /** processed and usable in a slot; not reviewed yet: goes into a review submission */ + unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'], + /** in a submission or reviewed: usable in a slot, nothing to add */ + reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'], +} as const; +export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown'; + +/** What a library asset's state means for storebound. Pure. */ +export function classify(state: string | undefined): AssetClass { + const s = String(state ?? ''); + for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass; + if (s === 'FAILED') return 'failed'; + if (s === 'REJECTED') return 'rejected'; + if (s === 'ARCHIVED') return 'archived'; + return s ? 'unknown' : 'processing'; +} + +/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */ +export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing'; + +/** Stop re-uploading a file whose earlier uploads failed processing this many times. */ +export const MAX_FAILED = 2; + +/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing), + * else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */ +export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } { + const mine = assets.filter(x => sumOf(x) === sum); + const rank: Record = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 }; + const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c); + const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]); + if (live[0]) return { asset: live[0] }; + if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true }; + const rej = by('rejected')[0]; + if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` }; + if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` }; + return {}; +} + +export const reason = (a: J): string => { + const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean); + return d.length ? ` (${d.join(', ')})` : ''; +}; + +/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */ +export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) }; + diff --git a/cli/apple/creative-assets.ts b/cli/apple/creative-assets.ts index 7539022..78866a3 100644 --- a/cli/apple/creative-assets.ts +++ b/cli/apple/creative-assets.ts @@ -29,61 +29,17 @@ import { need } from '../lib/ctx'; import { consoleLink } from '../lib/console-links'; import { done, log, warn } from '../lib/log'; import { elapsed } from '../lib/progress'; -import * as rt from '../lib/rt'; import { ffprobe } from '../shots/report'; import { imageInfo } from '../shots/spec'; import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative'; import { type Asc, ascClient, md5, type J } from './asc'; +import { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, reason, REF, selectable, sumOf } from './asset-library'; +export { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, REF, selectable }; import { partHeaders } from './screenshot-health'; import { AmbiguousWriteError } from '../lib/upsert'; import { addItem, ensureDraft, OtherSubmissionError } from './submission'; const TAG = 'apple'; -export const REF = (sum: string): string => `storebound ${sum}`; -const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1]; - -/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */ -export const ASSET_STATES = { - /** still uploading / processing: wait (bounded), then select */ - processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'], - /** processed and usable in a slot; not reviewed yet: goes into a review submission */ - unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'], - /** in a submission or reviewed: usable in a slot, nothing to add */ - reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'], -} as const; -export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown'; - -/** What a library asset's state means for storebound. Pure. */ -export function classify(state: string | undefined): AssetClass { - const s = String(state ?? ''); - for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass; - if (s === 'FAILED') return 'failed'; - if (s === 'REJECTED') return 'rejected'; - if (s === 'ARCHIVED') return 'archived'; - return s ? 'unknown' : 'processing'; -} - -/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */ -export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing'; - -/** Stop re-uploading a file whose earlier uploads failed processing this many times. */ -export const MAX_FAILED = 2; - -/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing), - * else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */ -export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } { - const mine = assets.filter(x => sumOf(x) === sum); - const rank: Record = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 }; - const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c); - const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]); - if (live[0]) return { asset: live[0] }; - if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true }; - const rej = by('rejected')[0]; - if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` }; - if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` }; - return {}; -} - /** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later". * The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that * is not APPROVED waits; a later run places it. Pure. */ @@ -107,14 +63,6 @@ export async function creativeWaiting(c: Asc, appId: string, local: Map { - const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean); - return d.length ? ` (${d.join(', ')})` : ''; -}; - -/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */ -export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) }; - export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'reuse' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J } /** Local files per App Store locale, checked; throws on any error before a network call. */ diff --git a/cli/apple/duo-library.ts b/cli/apple/duo-library.ts index 8e090fc..76c2383 100644 --- a/cli/apple/duo-library.ts +++ b/cli/apple/duo-library.ts @@ -8,18 +8,18 @@ // 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. +// removed and the files placed in order (outer display files first, then inner). Never truncated: more files than +// the limit Apple publishes for the group (refData groupLimits) changes nothing and says so. 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 { classify, pickAsset, processingTiming, REF, selectable } from './asset-library'; 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]; /** iPhone placement profile group ids (platform IOS or IPHONE_APP_STORE, as the live API answers) of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */ @@ -30,26 +30,44 @@ export function duoGroups(refData: J[]): string[] { return [...ids]; } +/** Apple's screenshot limit for a placement group on a version page, or null when refData publishes none. Pure. */ +export function duoLimit(refData: J[], group: string): number | null { + for (const r of refData) for (const f of (r?.attributes?.features ?? []) as J[]) { + if (f?.featureId !== 'APP_STORE_VERSIONS') continue; + for (const p of (f.placementPolicies ?? []) as J[]) { + if (p?.placementType !== 'APP_SCREENSHOT') continue; + for (const l of (p.groupLimits ?? []) as J[]) if ((l?.groupIds ?? []).includes(group) && typeof l.maxCount === 'number') return l.maxCount; + } + } + return null; +} + /** 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 { +export async function duoGroup(c: Asc): Promise<{ id: string; limit: number | null }> { 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]!; + const limit = duoLimit(ref, g[0]!); + log(TAG, `iPhone Duo: placement group ${g[0]}, limit ${limit ?? 'not published'}`); + return { id: g[0]!, limit }; } /** 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 { +export async function syncDuo(c: Asc, o: { appId: string; group: string; limit: number | null; 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 files = o.files; + if (o.limit !== null && files.length > o.limit) { + warn(TAG, `${tag}: ${files.length} files (outer + inner) but Apple allows ${o.limit} in this group: nothing changed, none dropped. Keep at most ${o.limit} across iphone-duo-outer/ and iphone-duo-inner/`); + process.exitCode = 1; + return { locale, action: 'refuse', files: files.length }; + } 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(); diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index de2f3fe..f2439fd 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -1,6 +1,6 @@ // Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match // by MD5 and order is left alone, otherwise it is emptied and re-uploaded). -// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry] +// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry] // storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place) // Source per language: //ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67), // ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 -> @@ -98,7 +98,7 @@ export async function run(ctx: Ctx): Promise { log('apple', `screenshots for version ${versionLabel(version)}`); let duo = platform === 'MAC_OS' ? [] : duoDirs(config.apple); // a Duo problem never stops the other sets - const duoGroupId = duo.length ? await duoGroup(c).catch((e: Error) => { warn('apple', `iPhone Duo skipped: ${e.message}`); duo = []; return ''; }) : ''; + const duoGroupId = duo.length ? await duoGroup(c).catch((e: Error) => { warn('apple', `iPhone Duo skipped: ${e.message}`); duo = []; return null; }) : null; 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'); @@ -121,16 +121,16 @@ export async function run(ctx: Ctx): Promise { if (!lang) { warn('apple', `${locale}: no language with apple: "${locale}", skipped`); continue; } tasks.push(async () => { const sets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`); - for (const [dir, type] of Object.entries(displaySets(config.apple, platform))) { + for (const [dir, type] of Object.entries(ctx.args.has('duo-only') ? {} : displaySets(config.apple, platform))) { const d = join(src, lang.id, dir); const files = numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10); 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) { + if (duo.length && duoGroupId) { 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') }).catch((e: Error) => { warn('apple', `${locale} iPhone Duo: ${e.message}`); process.exitCode = 1; }); + else await syncDuo(c, { appId, group: duoGroupId.id, limit: duoGroupId.limit, loc, files, dry: ctx.args.has('dry') }).catch((e: Error) => { warn('apple', `${locale} iPhone Duo: ${e.message}`); process.exitCode = 1; }); } }); } diff --git a/cli/data/flags.json b/cli/data/flags.json index b8058b6..fcb31d3 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -106,7 +106,7 @@ "name" ], "apple cpp": [ - "add-for-review", + "duo-only", "include-drafts", "jobs", "name", @@ -117,7 +117,6 @@ "repair-stuck", "src", "variant", - "verify", "write" ], "apple creative-assets": [ @@ -206,7 +205,7 @@ "write" ], "apple ppo": [ - "add-for-review", + "duo-only", "experiment", "include-drafts", "jobs", @@ -218,7 +217,6 @@ "src", "traffic", "variant", - "verify", "write" ], "apple preorder": [ @@ -274,7 +272,7 @@ "platform" ], "apple screenshots": [ - "add-for-review", + "duo-only", "include-drafts", "jobs", "not-sent-for-review", @@ -283,7 +281,6 @@ "repair-stuck", "src", "variant", - "verify", "write" ], "apple signing": [ @@ -1119,6 +1116,7 @@ "cordova-android", "cordova-ios", "days", + "duo-only", "expo", "flutter", "godot", @@ -1250,6 +1248,7 @@ "device", "dir", "download", + "duo-only", "enable", "end", "entitlements", @@ -1594,7 +1593,7 @@ "storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from ] [--dry]" ], "apple screenshots": [ - "storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]", + "storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry]", "storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)" ], "apple signing": [ diff --git a/tests/ppo.test.ts b/tests/ppo.test.ts index 0d147fa..b63e096 100644 --- a/tests/ppo.test.ts +++ b/tests/ppo.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { displaySets, duoDirs } from '../cli/apple/screenshots'; -import { duoGroups, duoUpToDate } from '../cli/apple/duo-library'; +import { duoGroups, duoLimit, 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'; @@ -105,6 +105,9 @@ describe('iPhone Duo (foldable) sizes', () => { expect(duoGroups(ref)).toEqual(['g-duo']); expect(duoGroups([])).toEqual([]); expect(duoGroups([{ attributes: { placementProfileGroups: [{ placementProfileGroupId: 'IPHONE_DUO_PROFILE', platform: 'IPHONE_APP_STORE', displayClassId: 'IPHONE_DUO' }] } }])).toEqual(['IPHONE_DUO_PROFILE']); + const lim = [{ attributes: { features: [{ featureId: 'IN_APP_EVENTS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 1 }] }] }, { featureId: 'APP_STORE_VERSIONS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 20 }] }] }] } }]; + expect(duoLimit(lim, 'g')).toBe(20); + expect(duoLimit(lim, 'other')).toBeNull(); expect(duoUpToDate(['a', 'b'], ['a', 'b'])).toBe(true); expect(duoUpToDate(['b', 'a'], ['a', 'b'])).toBe(false); expect(duoUpToDate(['a', undefined], ['a', 'b'])).toBe(false);