iPhone Duo screenshots (render, check, upload skip with console link while the API has no display type) and the medium display class as the required iPhone set, labels as in the console
This commit is contained in:
parent
0b32df57f0
commit
3381673ae6
17 changed files with 163 additions and 16 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- **iPhone Duo screenshots.** `apple.iphoneDuo: true` adds the foldable's outer (1398 x 2034) and inner (2007 x 2853)
|
||||
sets, portrait or landscape, to `shots render`, `shots check` (exact sizes, no alpha) and the gallery. App Store
|
||||
Connect API 4.5.1 has no Duo `ScreenshotDisplayType`, so `apple screenshots` skips them with a message and the deep
|
||||
link to the version page's App Previews and Screenshots tab (one constant, `IPHONE_DUO_DISPLAY`, takes the enum
|
||||
names once Apple adds them). `check` / `validate` note a missing Duo set: optional now, required from April 2027 for
|
||||
iOS 27.1 SDK builds (a warning after that date, never a blocker), and point Play to the tablet sets (no foldable slot).
|
||||
- **Required iPhone class follows the console.** `validate` now requires "iPhone with Dynamic Island (medium display)"
|
||||
(`APP_IPHONE_61`) per locale, met by a 6.9" or 6.5" set that App Store Connect scales down (an ok row, not a
|
||||
warning). Labels match the console: "iPhone with Dynamic Island (medium display)", "iPhone Duo", "iPad 13" display".
|
||||
|
||||
## 0.8.12 · 2026-10-17
|
||||
|
||||
- **Shareable readiness report.** `storebound report` puts `scan`, `check` and `validate` into one self-contained HTML
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { numberedImages } from '../play/images';
|
|||
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 { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
|
||||
|
||||
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
|
||||
|
|
@ -28,12 +30,22 @@ export const MEDIUM_DISPLAY: Record<string, string> = { 'iphone-medium': 'APP_IP
|
|||
|
||||
/** Display sets this project uploads: the iPhone sets, plus the 6.3" set with apple.iphoneMedium and the 13" iPad set
|
||||
* with apple.ipad. */
|
||||
export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean } | undefined): Record<string, string> =>
|
||||
({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}) });
|
||||
export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iphoneDuo?: boolean } | undefined): Record<string, string> =>
|
||||
({ ...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])) : {}) });
|
||||
|
||||
/** Display types `validate` requires on every locale: apple.screenshotTypes, else 6.9" iPhone (+ 13" iPad with ipad). */
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* scaling a 6.9" or 6.5" set down (ACCEPTED_INSTEAD), plus iPad 13" display with ipad. iPhone Duo is optional. */
|
||||
export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] =>
|
||||
apple?.screenshotTypes ?? ['APP_IPHONE_67', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
||||
apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
||||
|
||||
/** Makes one screenshot set (of a version localization, a custom product page localization or a Product Page
|
||||
* Optimization treatment localization) hold exactly `files`, in order: left alone when the MD5s match, else the
|
||||
|
|
@ -73,6 +85,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const version = await c.editableVersion();
|
||||
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
|
||||
const duoSkip = skippedDuoSets(config.apple);
|
||||
if (duoSkip) warn('apple', duoSkip.msg);
|
||||
const langOf = byAppleLocale(config.languages);
|
||||
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
|
||||
|
||||
|
|
|
|||
|
|
@ -408,7 +408,7 @@ type LiveSet = LiveArtSet;
|
|||
type LiveResult = LiveArtResult;
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'App Store iPhone 6.3"', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'App Store iPad 13"', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)',
|
||||
APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'iPad 13" display', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)',
|
||||
IPHONE_67: 'App Store iPhone app preview', IPAD_PRO_3GEN_129: 'App Store iPad app preview', featureGraphic: 'Play feature graphic', phoneScreenshots: 'Play phone', sevenInchScreenshots: 'Play 7" tablet', tenInchScreenshots: 'Play 10" tablet', tvScreenshots: 'Play TV', wearScreenshots: 'Play Wear OS',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { allSubscriptions } from './lib/subscriptions';
|
|||
import { type CopyFile, validateCopy } from './shots/lib';
|
||||
import { posix, readPulled } from './lib/art-pull';
|
||||
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
|
||||
import { iphoneDuoNote } from './shots/spec';
|
||||
|
||||
/** The newest .ipa in a folder (apple build's export), or null. */
|
||||
function newestIpa(dir: string): string | null {
|
||||
|
|
@ -146,6 +147,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try { for (const n of (await runScan(ctx.loaded.root, { config: ctx.loaded.path })).report.notes) if (n.startsWith('scan.ignore[')) note(n); } catch (e) { note(`scan.ignore not checked: ${(e as Error).message}`); }
|
||||
}
|
||||
if (config.apple && !config.app.copyright?.trim()) problems.push(COPYRIGHT_MISSING);
|
||||
const duo = iphoneDuoNote({ apple: !!config.apple, duo: !!config.apple?.iphoneDuo, play: !!config.play });
|
||||
if (duo?.level === 'info') { hints.push(duo.msg); log('check', `hint: ${duo.msg}`); } else if (duo) note(duo.msg);
|
||||
// the scaffolder's own placeholders would reach the stores as the app's copyright, contact and legal pages
|
||||
const tph = templatePlaceholders(config as never);
|
||||
if (tph.length) problems.push(`template placeholders still in the config: ${tph.join(', ')} ("Example Studio", example.com): the stores would show them as your copyright, contact address and legal pages; put the real values in`);
|
||||
|
|
|
|||
|
|
@ -622,6 +622,10 @@ export interface ShipConfig {
|
|||
* pixel-exact art on 6.3" phones, or if Apple stops scaling. (Screenshot specifications, App Store Connect Help.)
|
||||
*/
|
||||
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). */
|
||||
iphoneDuo?: boolean;
|
||||
/** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */
|
||||
testflight?: TfConfig;
|
||||
/** Certificates, profiles and capabilities through the API (`storebound apple signing`), signing style of `apple build`. */
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { consoleLink, consoleUrl } from './console-links';
|
||||
import { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions';
|
||||
import { iphoneDuoNote } from '../shots/spec';
|
||||
|
||||
export type Level = 'blocker' | 'warn' | 'ok';
|
||||
|
||||
|
|
@ -11,11 +12,16 @@ export type Level = 'blocker' | 'warn' | 'ok';
|
|||
* (either 13" display type). The smaller sizes are scaled down from these. A live app with only the 6.5" set is fine
|
||||
* (store-pitfalls 44). */
|
||||
export const ACCEPTED_INSTEAD: Record<string, string[]> = {
|
||||
// the console's required iPhone class (medium display): smaller sizes scale down from a larger set automatically
|
||||
APP_IPHONE_61: ['APP_IPHONE_67', 'APP_IPHONE_65'],
|
||||
APP_IPHONE_67: ['APP_IPHONE_65'],
|
||||
APP_IPHONE_65: ['APP_IPHONE_67'],
|
||||
APP_IPAD_PRO_3GEN_129: ['APP_IPAD_PRO_129'],
|
||||
APP_IPAD_PRO_129: ['APP_IPAD_PRO_3GEN_129'],
|
||||
};
|
||||
/** Required class -> larger sets App Store Connect scales down into it ("View All Sizes in Media Manager"). */
|
||||
const SCALES_DOWN: Record<string, string[]> = { APP_IPHONE_61: ['APP_IPHONE_67', 'APP_IPHONE_65'] };
|
||||
const LABEL: Record<string, string> = { APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPAD_PRO_3GEN_129: 'iPad 13" display' };
|
||||
export interface Finding { store: 'apple' | 'play'; level: Level; code: string; msg: string; fix?: string;
|
||||
/** Absolute deep link to the console page the message points to (desktop / MCP render it as an Open link). */
|
||||
url?: string;
|
||||
|
|
@ -58,6 +64,10 @@ export interface AppleSnapshot {
|
|||
expectedLocales: string[];
|
||||
locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; screenshots: Record<string, number> }[];
|
||||
requiredDisplayTypes: string[];
|
||||
/** apple.iphoneDuo (iPhone Duo renders on) and whether the project has a play section, for the Duo note. */
|
||||
iphoneDuo?: boolean;
|
||||
play?: boolean;
|
||||
today?: Date;
|
||||
reviewDetail: { contactEmail?: string | null; contactPhone?: string | null; contactFirstName?: string | null; contactLastName?: string | null; demoAccountRequired?: boolean; demoAccountName?: string | null; notes?: string | null } | null;
|
||||
/** Age rating declaration attributes (null = not readable). */
|
||||
ageRating: Record<string, unknown> | null;
|
||||
|
|
@ -212,7 +222,8 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
for (const t of s.requiredDisplayTypes) {
|
||||
if (l.screenshots[t]) continue;
|
||||
const alt = (ACCEPTED_INSTEAD[t] ?? []).find(x => l.screenshots[x]);
|
||||
if (alt) add('warn', 'screenshots-size', `${l.locale}: no ${t} set; Apple accepts the ${alt} set (${l.screenshots[alt]} screenshots) and scales it for the other sizes. Recommended, not required: add ${t}`, 'storebound apple screenshots');
|
||||
if (alt && SCALES_DOWN[t]?.includes(alt)) add('ok', 'screenshots-size', `${l.locale}: ${LABEL[t] ?? t} filled by scaling the ${alt} set down (${l.screenshots[alt]} screenshots)`);
|
||||
else if (alt) add('warn', 'screenshots-size', `${l.locale}: no ${t} set; Apple accepts the ${alt} set (${l.screenshots[alt]} screenshots) and scales it for the other sizes. Recommended, not required: add ${t}`, 'storebound apple screenshots');
|
||||
else add('blocker', 'screenshots', `${l.locale}: no ${t} screenshots${ACCEPTED_INSTEAD[t] ? ` (or ${ACCEPTED_INSTEAD[t]!.join(' / ')})` : ''}`, 'storebound apple screenshots');
|
||||
}
|
||||
}
|
||||
|
|
@ -254,6 +265,9 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
|
||||
if (s.profile) f.push(...profileRules('apple', s.profile, { reviewDetail: s.reviewDetail, descriptions: s.locales.map(l => ({ locale: l.locale, text: l.description ?? '' })) }));
|
||||
f.push(...rendersFindings('apple', s.renders));
|
||||
// iPhone Duo: optional until April 2027, so a passing note (never a blocker); a warning after that date
|
||||
const duo = iphoneDuoNote({ apple: true, duo: !!s.iphoneDuo, play: s.play, today: s.today });
|
||||
if (duo) add(duo.level === 'info' ? 'ok' : 'warn', 'screenshots-duo', duo.msg, 'apple.iphoneDuo: true, then storebound shots render');
|
||||
return sortFindings(f);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export const TOOLS: ToolDef[] = [
|
|||
...(i.concurrency ? ['--concurrency', String(i.concurrency)] : []), ...(i.mode === 'force' ? ['--force'] : i.mode === 'only-missing' ? ['--only-missing'] : [])] },
|
||||
{ name: 'shots_render', title: 'Render store screenshots', command: ['shots', 'render'], write: true, network: false, idempotent: true,
|
||||
description: 'Store screenshots + Play feature graphic in every size and language from the frames and the copy file (local PNGs, needs Playwright + ffmpeg; slow for many languages). dry_run lists what would be rendered.',
|
||||
props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, ipad13, play, tab7, tab10, feature' },
|
||||
props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, iphone-duo-outer, iphone-duo-inner, ipad13, play, tab7, tab10, feature' },
|
||||
scenes: { type: 'string', description: 'Comma-separated scene ids' }, variant, all_variants: { type: 'boolean', default: false, description: 'Base + every variant' },
|
||||
concurrency: { type: 'integer', minimum: 1, maximum: 32, description: 'Parallel pages (default min(4, CPU cores / 2))' } },
|
||||
args: i => [...opt('lang', i.lang), ...opt('size', i.size), ...opt('scenes', i.scenes), ...(i.all_variants === true ? ['--variants'] : opt('variant', i.variant)),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export const SIZES: Record<string, { w: number; h: number; device?: 'tablet'; fe
|
|||
ios65: { w: 1284, h: 2778 },
|
||||
'iphone-medium': { w: 1206, h: 2622 },
|
||||
ipad13: { w: 2064, h: 2752, device: 'tablet' },
|
||||
// iPhone Duo (foldable), only with apple.iphoneDuo: near-tablet aspect, so the tablet frames fill it
|
||||
'iphone-duo-outer': { w: 1398, h: 2034, device: 'tablet' },
|
||||
'iphone-duo-inner': { w: 2007, h: 2853, device: 'tablet' },
|
||||
play: { w: 1080, h: 1920 },
|
||||
tab7: { w: 1080, h: 1920, device: 'tablet' },
|
||||
tab10: { w: 1440, h: 2560, device: 'tablet' },
|
||||
|
|
@ -120,13 +123,14 @@ export function frameDims(config: Pick<ShipConfig, 'apple' | 'shots'>): Record<'
|
|||
export const PLAY_TABLET_SIZES: readonly string[] = ['tab7', 'tab10'];
|
||||
|
||||
/** Size keys this project renders by default: Apple sizes only with an apple section (iPad only with apple.ipad, the
|
||||
* 6.3" iPhone only with apple.iphoneMedium),
|
||||
* 6.3" iPhone only with apple.iphoneMedium, the iPhone Duo sizes only with apple.iphoneDuo),
|
||||
* Play sizes only with a play section (both when neither is configured); the Play tablet sizes only when `tablets`
|
||||
* (resolved play.tablets, see resolvedTablets in report.ts; unset here = play.tablets ?? true). */
|
||||
export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>, tablets?: boolean): string[] {
|
||||
const apple = !!config.apple || !config.play, play = !!config.play || !config.apple;
|
||||
const tabs = tablets ?? config.play?.tablets ?? true;
|
||||
return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium
|
||||
: k.startsWith('iphone-duo') ? apple && !!config.apple?.iphoneDuo
|
||||
: k.startsWith('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,10 +40,14 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
|
|||
// 6.3" iPhone with Dynamic Island, medium display (iPhone 14 Pro .. 18 Pro): optional here (apple.iphoneMedium). Apple
|
||||
// scales the 6.5" (Face ID large) set down for it when it is missing. APP_IPHONE_61 is inferred: the enum has no
|
||||
// descriptions and 61 is the only medium iPhone bucket (NOT VERIFIED against an upload).
|
||||
'iphone-medium': { store: 'apple', type: 'APP_IPHONE_61', label: 'App Store iPhone 6.3"', sizes: [[1206, 2622], [1179, 2556]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
'iphone-medium': { store: 'apple', type: 'APP_IPHONE_61', label: 'iPhone with Dynamic Island (medium display)', sizes: [[1206, 2622], [1179, 2556]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
ios65: { store: 'apple', type: 'APP_IPHONE_65', label: 'App Store iPhone 6.5"', sizes: [[1284, 2778], [1242, 2688]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
// 13" iPad display (iPad Pro M4/M5, iPad Air M2+): required when the app runs on iPad. API display type keeps the old name.
|
||||
ipad13: { store: 'apple', type: 'APP_IPAD_PRO_3GEN_129', label: 'App Store iPad 13"', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
ipad13: { store: 'apple', type: 'APP_IPAD_PRO_3GEN_129', label: 'iPad 13" display', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
// iPhone Duo (foldable, Screenshot specifications, October 2026): outer and inner display, portrait or landscape,
|
||||
// optional (apple.iphoneDuo). `type` is not an ASC display type: see IPHONE_DUO_DISPLAY. No app preview sizes yet.
|
||||
'iphone-duo-outer': { store: 'apple', type: 'IPHONE_DUO_OUTER', label: 'iPhone Duo (outer display)', sizes: [[1398, 2034]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
'iphone-duo-inner': { store: 'apple', type: 'IPHONE_DUO_INNER', label: 'iPhone Duo (inner display)', sizes: [[2007, 2853]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||
play: { store: 'play', type: 'phoneScreenshots', label: 'Play phone', sizes: [[1080, 1920]], min: 2, max: 8, maxBytes: 8 * MB, alpha: false },
|
||||
tab7: { store: 'play', type: 'sevenInchScreenshots', label: 'Play 7" tablet', sizes: [[1080, 1920]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false },
|
||||
tab10: { store: 'play', type: 'tenInchScreenshots', label: 'Play 10" tablet', sizes: [[1440, 2560]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false },
|
||||
|
|
@ -51,6 +55,23 @@ 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 checked 2026-10-18) 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. */
|
||||
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
|
||||
export const IPHONE_DUO_NOT_IN_API = 'App Store Connect does not take iPhone Duo screenshots through the API yet; storebound keeps them 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)';
|
||||
|
||||
/** check / validate note for an iPhone app without iPhone Duo screenshots: info before IPHONE_DUO_REQUIRED_FROM, a
|
||||
* warning after (never an error: the rule depends on the SDK the build uses). null when Duo renders exist or no iPhone app. */
|
||||
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${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
|
||||
}
|
||||
|
||||
/** Play screenshots in general (any size the store accepts): 320..3840 px per side, long side at most 2x the short. */
|
||||
export function playScreenshotOk(w: number, h: number): boolean {
|
||||
const lo = Math.min(w, h), hi = Math.max(w, h);
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
build, locales, reviewDetail, ageRating,
|
||||
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
||||
requiredDisplayTypes: requiredDisplayTypes(config.apple),
|
||||
iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play,
|
||||
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
|
||||
availability: !!avail?.data,
|
||||
firstProductGaps,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ languages, every store size, a review gallery and an app preview video. No store
|
|||
6. **Video.** `shots record` films the autoplay mode, `shots video` cuts 15-30 s App Store previews (886 x 1920,
|
||||
1200 x 1600) and a 9:16 / 16:9 promo for YouTube with captions in every language, `apple previews` uploads them.
|
||||
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.
|
||||
|
||||
Details: the [store-screenshots](../skills/store-screenshots/SKILL.md) and [app-previews](../skills/app-previews/SKILL.md)
|
||||
skills.
|
||||
|
|
|
|||
|
|
@ -899,3 +899,15 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
`cli/lib/scan/rules.ts`, the SDK table `REMOTE_SDKS` in `cli/lib/scan/sdks.ts`). A code obfuscator (DexGuard,
|
||||
iXGuard, a JavaScript obfuscator) is a low `obfuscation` note; ProGuard / R8 minify alone is never reported.
|
||||
`tests/remote-features.test.ts`.
|
||||
|
||||
## 75. iPhone Duo screenshots: in the console, not in the API
|
||||
|
||||
- **Symptom:** App Store Connect shows an "iPhone Duo" tab next to "iPhone with Dynamic Island (medium display)" and
|
||||
"iPad 13" display", but an upload for it through the API has no display type to use (a guessed one answers 400).
|
||||
- **Cause:** the API's `ScreenshotDisplayType` (4.5.1) has no Duo value yet; only the asset library enum lists
|
||||
`IPHONE_DUO`. Duo is optional now, reportedly required from April 2027 for iOS 27.1 SDK builds.
|
||||
- **Do:** render and check the sets (`apple.iphoneDuo: true`), add them in the console's iPhone Duo tab (the skip
|
||||
message has the deep link), never guess an enum name. For Play point foldables to the tablet sets; there is no Duo slot.
|
||||
- **Guard:** `IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts` (null = not in the API; regenerate with
|
||||
`bun tools/gen-api.ts` and fill in the exact names), `skippedDuoSets` in `cli/apple/screenshots.ts`, the
|
||||
`screenshots-duo` note in check / validate. `tests/ppo.test.ts`.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ or uploaded as another size. `store/renders` can be large (hundreds of MB) and n
|
|||
|---|---|---|
|
||||
| ios69 | 1320 x 2868 | App Store 6.9" (`APP_IPHONE_67`, "Dynamic Island (large display)"); the set storebound requires |
|
||||
| ios65 | 1284 x 2778 | App Store 6.5" (`APP_IPHONE_65`) |
|
||||
| iphone-duo-outer | 1398 x 2034 (or 2034 x 1398) | App Store "iPhone Duo" outer display; optional, only with `apple.iphoneDuo: true` (tablet frames) |
|
||||
| iphone-duo-inner | 2007 x 2853 (or 2853 x 2007) | App Store "iPhone Duo" inner display; same switch |
|
||||
| ipad13 | 2064 x 2752 | App Store 13" iPad (`APP_IPAD_PRO_3GEN_129`), required when the app runs on iPad; only with `apple.ipad: true` |
|
||||
| play | 1080 x 1920 | Play phone (2-8, ratio at most 2:1) |
|
||||
| tab7 | 1080 x 1920 | Play 7" tablet (tablet frames, Android tablet body); optional, see `play.tablets` |
|
||||
|
|
@ -61,6 +63,18 @@ to the 13" iPad screen (1032 x 1352 CSS px @2x below a 24 px bar), so an `iPad P
|
|||
simulator screenshot fits after the bar is cropped. Play tablet sizes (tab7 / tab10) reuse the same tablet frames, drawn
|
||||
in a plain Android tablet body instead of the iPad one.
|
||||
|
||||
**iPhone Duo (foldable).** App Store Connect's "App Previews and Screenshots" tab now lists three classes:
|
||||
"iPhone with Dynamic Island (medium display)" (required for iPhone; a 6.9" or 6.5" set scales down into it, so
|
||||
`validate` passes with the `ios69` set), "iPhone Duo" and "iPad 13" display" (required with `apple.ipad`). Duo takes an
|
||||
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
|
||||
7" / 10" tablet sets below.
|
||||
|
||||
**Play tablets are optional.** Play requires the 512 x 512 icon, the 1024 x 500 feature graphic and at least 2
|
||||
screenshots (answer 9866151 above); 7" / 10" tablet screenshots are optional, but without them the app is not eligible
|
||||
for tablet featuring. `play.tablets` decides: unset = true when tablet renders (tab7/ or tab10/) already exist, else
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ const config: ShipConfig = {
|
|||
// usesNonExemptEncryption: false,
|
||||
// ipad: true, // the app runs on iPad: 13" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded
|
||||
// iphoneMedium: true, // optional 6.3" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5" set down
|
||||
// screenshotTypes: ['APP_IPHONE_67'], // what `storebound validate` requires per locale (default; + APP_IPAD_PRO_3GEN_129 with ipad)
|
||||
// iphoneDuo: true, // optional iPhone Duo (foldable) sets, outer 1398 x 2034 + inner 2007 x 2853; rendered and checked, not uploaded until the API takes them
|
||||
// screenshotTypes: ['APP_IPHONE_61'], // what `storebound validate` requires per locale (default: medium display, met by a 6.9" or 6.5" set; + APP_IPAD_PRO_3GEN_129 with ipad)
|
||||
// TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.
|
||||
testflight: {
|
||||
groups: [
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { displaySets } from '../cli/apple/screenshots';
|
||||
import { displaySets, skippedDuoSets } from '../cli/apple/screenshots';
|
||||
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 } from '../cli/shots/spec';
|
||||
import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API, iphoneDuoNote } from '../cli/shots/spec';
|
||||
import { png } from './fixtures';
|
||||
|
||||
describe('product page optimization', () => {
|
||||
|
|
@ -74,3 +75,47 @@ describe('optional 6.3" iPhone size (iphone-medium)', () => {
|
|||
expect(checkImage(imageInfo(png(1320, 2868)), 'iphone-medium').map(i => i.code)).toEqual(['size']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('iPhone Duo (foldable) sizes', () => {
|
||||
test('device table: outer and inner, portrait and landscape; opt-in with apple.iphoneDuo', () => {
|
||||
expect(SIZES['iphone-duo-outer']).toMatchObject({ w: 1398, h: 2034 });
|
||||
expect(SIZES['iphone-duo-inner']).toMatchObject({ w: 2007, h: 2853 });
|
||||
expect(defaultSizes({ apple: { bundleId: 'x' } })).not.toContain('iphone-duo-outer');
|
||||
expect(defaultSizes({ apple: { bundleId: 'x', iphoneDuo: true } })).toEqual(expect.arrayContaining(['iphone-duo-outer', 'iphone-duo-inner']));
|
||||
expect(defaultSizes({ play: { packageName: 'x' }, apple: undefined })).not.toContain('iphone-duo-inner');
|
||||
});
|
||||
test('shots check: the 4 exact sizes pass, others and alpha fail', () => {
|
||||
for (const [k, w, h] of [['iphone-duo-outer', 1398, 2034], ['iphone-duo-outer', 2034, 1398], ['iphone-duo-inner', 2007, 2853], ['iphone-duo-inner', 2853, 2007]] as const)
|
||||
expect(checkImage(imageInfo(png(w, h)), k)).toEqual([]);
|
||||
expect(checkImage(imageInfo(png(1398, 2035)), 'iphone-duo-outer').map(i => i.code)).toEqual(['size']);
|
||||
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', () => {
|
||||
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');
|
||||
});
|
||||
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');
|
||||
expect(iphoneDuoNote({ apple: true, duo: true })).toBeNull();
|
||||
expect(iphoneDuoNote({ apple: false, duo: false })).toBeNull();
|
||||
const n = iphoneDuoNote({ apple: true, duo: false, play: true, today: before })!;
|
||||
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(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')!;
|
||||
expect(f.level).toBe('ok');
|
||||
expect(appleRules({ ...base, iphoneDuo: true, today: before } as unknown as Parameters<typeof appleRules>[0]).some(x => x.code === 'screenshots-duo')).toBe(false);
|
||||
});
|
||||
test('validate: the medium display class is required; a 6.9" set fills it (scaled down), none is a blocker', () => {
|
||||
const base = { version: null, build: null, expectedLocales: [], requiredDisplayTypes: ['APP_IPHONE_61'], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } };
|
||||
const run = (screenshots: Record<string, number>) => appleRules({ ...base, locales: [{ locale: 'en-US', description: 'd', keywords: 'k', subtitle: 's', screenshots }] } as unknown as Parameters<typeof appleRules>[0]);
|
||||
expect(run({ APP_IPHONE_67: 5 }).find(x => x.code === 'screenshots-size')!.level).toBe('ok');
|
||||
expect(run({}).find(x => x.code === 'screenshots')!.level).toBe('blocker');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ describe('13" iPad', () => {
|
|||
expect(displaySets({})).toEqual(DISPLAY);
|
||||
expect(displaySets(undefined)).toEqual(DISPLAY);
|
||||
expect(Object.keys(displaySets({ ipad: true }))).toEqual(['ios69', 'ios65', 'ipad13']);
|
||||
expect(requiredDisplayTypes({})).toEqual(['APP_IPHONE_67']);
|
||||
expect(requiredDisplayTypes({ ipad: true })).toEqual(['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129']);
|
||||
expect(requiredDisplayTypes({})).toEqual(['APP_IPHONE_61']);
|
||||
expect(requiredDisplayTypes({ ipad: true })).toEqual(['APP_IPHONE_61', 'APP_IPAD_PRO_3GEN_129']);
|
||||
expect(requiredDisplayTypes({ ipad: true, screenshotTypes: ['APP_IPHONE_65'] })).toEqual(['APP_IPHONE_65']);
|
||||
expect(defaultSizes({ apple: { bundleId: 'x' } })).not.toContain('ipad13');
|
||||
expect(defaultSizes({ apple: { bundleId: 'x', ipad: true } })).toContain('ipad13');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue