diff --git a/CHANGELOG.md b/CHANGELOG.md index 235ac25..91966cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Fix (`play images`): a phone-only project no longer aborts with "no sevenInchScreenshots ... (render first)". Play + requires the 512 icon, the 1024 x 500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151); + 7" / 10" tablet screenshots are optional. `play images` (dry and real) uploads what exists (icon, feature graphic, + phone screenshots; tablet, TV and Wear sets only when rendered) and prints `tablet screenshots: none (optional; the + app isn't eligible for tablet featuring without them)`. Missing required sets are still errors, with the fix. + - New `play.tablets?: boolean` (default: true when tablet renders exist, else false). `false`: `shots render`, + `shots check`, `shots gallery`, `validate` and `play images` never ask for tablet sets. `true`: rendered, and + missing ones are errors (local renders and the listing on Play). + - A missing optional set never deletes the screenshots Play has for it: the dry run shows `keeps on Play`; + new `--prune` deletes them (shown as `would delete` in the dry run; not exposed over MCP). Pitfall 19. + - Safety (`console fill`): storebound no longer copies the owner's Chrome cookies by default. A copied Google session reached Play Console once, then Google answered `/v3/signin/rejected`: Google binds its session cookies to the device (Chrome's Device Bound Session Credentials) and signs out sessions whose cookies look stolen, which can sign diff --git a/cli/commands.ts b/cli/commands.ts index 102d29a..cb11ae8 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -45,7 +45,7 @@ export const COMMANDS: Record Promise, string]> = { 'play products': [() => import('./play/products'), 'one-time products: create / update, regional prices, activate'], 'play subscriptions': [() => import('./play/subscriptions'), 'auto-renewable subscriptions: listings, base plan, regional prices, intro offer, activate (--csv prices sheet; asks first)'], 'play listing': [() => import('./play/listing'), 'app details + title / short / full description per language; pull [--write]: live listings back into the listing file (diff first)'], - 'play images': [() => import('./play/images'), 'icon, feature graphic, phone / 7" / 10" screenshots per language; --variant '], + 'play images': [() => import('./play/images'), 'icon, feature graphic, phone screenshots per language, optional tablet / TV / Wear sets when rendered (play.tablets); --variant , --prune'], 'play data-safety': [() => import('./play/data-safety'), '[--force] Data safety form (CSV import through the API; skipped when unchanged since the last upload)'], 'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes'], 'play build': [() => import('./play/build'), 'gradle bundleRelease (Flutter: flutter build appbundle) with the next free versionCode and the upload key from env / .storebound; --dry'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 6715cb9..540e680 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -74,6 +74,7 @@ "jobs", "name", "off", + "prune", "src", "variant", "write" @@ -120,6 +121,7 @@ "include-drafts", "jobs", "name", + "prune", "src", "traffic", "variant", @@ -152,6 +154,7 @@ "apple screenshots": [ "include-drafts", "jobs", + "prune", "src", "variant", "write" @@ -369,6 +372,7 @@ ], "play images": [ "include-drafts", + "prune", "src", "variant", "write" @@ -599,6 +603,7 @@ "no-scan", "out", "play", + "prune", "react-native", "src", "unity", @@ -760,6 +765,7 @@ "production", "profile", "project", + "prune", "q", "rating", "react-native", @@ -1027,7 +1033,7 @@ "storebound play data-safety [--csv out.csv] [--dry] [--force]" ], "play images": [ - "storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b]" + "storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune]" ], "play listing": [ "storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts]", diff --git a/cli/lib/config.ts b/cli/lib/config.ts index ed7c5c9..fcf16b5 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -509,6 +509,11 @@ export interface ShipConfig { name?: string; /** Free (true) or paid (false) on Play. A free app can never become paid later; storebound flags it for the owner. */ free?: boolean; + /** Play 7" and 10" tablet screenshots (renders tab7/ and tab10/). Optional on Play (required: icon, feature graphic, + * at least 2 phone screenshots), but without them the app is not eligible for tablet featuring. Default: true when + * tablet renders exist under the renders folder, else false. false = `shots render` / `shots check`, `validate` and + * `play images` never ask for them; true = missing tablet sets are errors. */ + tablets?: boolean; /** Promo video: a YouTube URL (public or unlisted, ads off, embeddable). Play takes no video upload. Sent by `play listing`. */ video?: string; /** Cloud Storage bucket of the Play Console report exports, e.g. "pubsite_prod_rev_" (Play Console > diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index 56ab8f6..1399dc6 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -98,6 +98,8 @@ export interface PlaySnapshot { expectedProducts: string[]; /** Local renders (Play sizes only), checked before upload. */ renders?: RendersCheck; + /** Resolved play.tablets: true = 7" and 10" tablet screenshots are required too. */ + tablets?: boolean; /** Play subscriptions and their base plans (validate reads them when the config has subscriptions). */ subscriptions?: { productId: string; basePlans: { basePlanId: string; state?: string }[] }[]; expectedSubscriptions?: { id: string; basePlanId: string }[]; @@ -186,7 +188,13 @@ export function appleRules(s: AppleSnapshot): Finding[] { return sortFindings(f); } +// Play Console Help, "Add preview assets to showcase your app" (answer 9866151, checked October 2026): an app icon and a +// feature graphic are required, and at least 2 screenshots; 7" / 10" tablet screenshots are optional (play.tablets). export const PLAY_REQUIRED_IMAGES: Record = { icon: 1, featureGraphic: 1, phoneScreenshots: 2 }; +/** Play tablet image types, required only with play.tablets (resolved: explicit, or tablet renders exist). */ +export const PLAY_TABLET_IMAGES: Record = { sevenInchScreenshots: 1, tenInchScreenshots: 1 }; +/** Image types validate requires on Play for a project. */ +export const playRequiredImages = (tablets: boolean | undefined): Record => (tablets ? { ...PLAY_REQUIRED_IMAGES, ...PLAY_TABLET_IMAGES } : PLAY_REQUIRED_IMAGES); /** app-ads.txt lines compared without spaces and case (the format is case-insensitive except the ids, which we keep). */ export const normAdsLine = (l: string) => l.split('#')[0]!.split(',').map(p => p.trim().toLowerCase()).filter(Boolean).join(','); @@ -200,7 +208,8 @@ export function playRules(s: PlaySnapshot): Finding[] { for (const l of s.listings) { const miss = (['title', 'shortDescription', 'fullDescription'] as const).filter(k => !l[k]); if (miss.length) add('blocker', 'listing', `${l.language}: missing ${miss.join(', ')}`, 'storebound play listing'); - for (const [type, n] of Object.entries(PLAY_REQUIRED_IMAGES)) if ((l.images[type] ?? 0) < n) add('blocker', 'images', `${l.language}: ${type} ${l.images[type] ?? 0}/${n}`, 'storebound play images'); + for (const [type, n] of Object.entries(playRequiredImages(s.tablets))) if ((l.images[type] ?? 0) < n) + add('blocker', 'images', `${l.language}: ${type} ${l.images[type] ?? 0}/${n}${PLAY_TABLET_IMAGES[type] ? ' (play.tablets is on)' : ''}`, 'storebound play images'); } if (s.listings.length && !f.some(x => x.code === 'listing' || x.code === 'images')) add('ok', 'listing', `${s.listings.length} listings with text and graphics`); diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index b0fac05..dd7fb25 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -153,7 +153,7 @@ export const TOOLS: ToolDef[] = [ description: 'Google Play app details + title / short / full description per language from the listing file. dry_run prints the plan (old -> new per field); draft languages are skipped (owner terminal run with --include-drafts). Not public until the next review / publish.', props: { only }, args: i => opt('only', i.only) }, { name: 'play_images', title: 'Upload Play graphics', command: ['play', 'images'], write: true, destructive: true, network: true, idempotent: true, - description: 'Replace icon, feature graphic and screenshots per Play language from the renders folder (or an A/B variant folder). Slow (minutes): prefer the terminal with a log for many languages.', + description: 'Replace icon, feature graphic and phone screenshots per Play language from the renders folder (or an A/B variant folder); optional 7" / 10" tablet, TV and Wear sets only when rendered (play.tablets). A missing optional set never deletes what Play has (--prune is an owner terminal run). Slow (minutes): prefer the terminal with a log for many languages.', props: { only, variant }, args: i => [...opt('only', i.only), ...opt('variant', i.variant)] }, { name: 'play_products', title: 'Sync Play one-time products', command: ['play', 'products'], write: true, network: true, idempotent: true, description: 'Create one-time products with regional prices and activate them; update listing text of existing ones. dry_run shows per product what would change. Price, region or activation changes of existing products are the owner\'s (terminal confirmation): here they are skipped with a warning.', diff --git a/cli/play/images.ts b/cli/play/images.ts index 6d857c3..1c83e99 100644 --- a/cli/play/images.ts +++ b/cli/play/images.ts @@ -1,13 +1,17 @@ // Play store graphics per language, one edit per language (a Console save in parallel then only costs a retry of that // language, not of the whole upload). -// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] -// Per Play language, from //: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*), -// phoneScreenshots (play/NN.*), sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*). A .jpg next to a -// .png wins (JPEG q92 uploads about 4x faster; `storebound shots jpeg` makes them). Play's images are compared with -// the local files first (sha256, in order): only a changed image type is cleared and uploaded, in one edit per -// language that is committed whole or not at all, so a re-run changes nothing and never doubles an image. --variant uploads ///... (an A/B variant from -// shots.variants) to the main listing: Play store listing experiments themselves are set up in Play Console (no API). -// Uploads are slow: run in the background with a log file. +// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] +// Per Play language, from //: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*) and +// phoneScreenshots (play/NN.*, at least 2) are required (Play Console Help answer 9866151). Optional, uploaded only +// when present: sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*), tvScreenshots (tv/NN.*), +// wearScreenshots (wear/NN.*). play.tablets (default: tablet renders exist) = true makes the two tablet sets required, +// false skips them. A missing optional set is an info line, never an abort, and never deletes what Play already has for +// that type unless --prune is given (the dry run shows it). A .jpg next to a .png wins (JPEG q92 uploads about 4x +// faster; `storebound shots jpeg` makes them). Play's images are compared with the local files first (sha256, in +// order): only a changed image type is cleared and uploaded, in one edit per language that is committed whole or not +// at all, so a re-run changes nothing and never doubles an image. --variant uploads ///... (an +// A/B variant from shots.variants) to the main listing: Play store listing experiments themselves are set up in Play +// Console (no API). Uploads are slow: run in the background with a log file. // --dry is read-only: one throwaway edit, the images Play has now (their sha256) compared with the local files per // language and type, then the edit is deleted. Nothing is uploaded or cleared, not even inside that edit. import { createHash } from 'node:crypto'; @@ -19,8 +23,11 @@ import { done, log, step } from '../lib/log'; import { playClient } from './client'; import { readPlayCopy } from './listing'; import { variantRoot } from '../shots/lib'; +import { resolvedTablets } from '../shots/report'; -export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10' } as const; +export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10', tvScreenshots: 'tv', wearScreenshots: 'wear' } as const; +export const PLAY_TABLET_TYPES: readonly string[] = ['sevenInchScreenshots', 'tenInchScreenshots']; +const OPTIONAL_TYPES = ['sevenInchScreenshots', 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots']; /** NN.png / NN.jpg in a dir, sorted, JPEG preferred over PNG of the same number. */ export function numberedImages(dir: string): string[] { @@ -35,7 +42,9 @@ export function numberedImages(dir: string): string[] { return [...byNum.keys()].sort((a, b) => Number(a) - Number(b)).map(n => join(dir, byNum.get(n)!)); } -export interface ImageChange { type: string; change: 'same' | 'replace'; remote: number; local: number } +/** same / replace: a local set against Play's. keep / delete / none: an optional set with no local files (Play's images + * are kept, deleted with --prune, or Play has none either). */ +export interface ImageChange { type: string; change: 'same' | 'replace' | 'keep' | 'delete' | 'none'; remote: number; local: number } /** One image type: what Play has (sha256 of each uploaded file, in order) against the local files' sha256. Play keeps * the uploaded bytes' hash, so equal lists mean an upload would change nothing. Pure. */ @@ -44,79 +53,143 @@ export function imageChange(type: string, remote: { sha256?: string }[], local: return { type, change: same ? 'same' : 'replace', remote: remote.length, local: local.length }; } +/** An optional type with no local files: Play's images stay unless `prune`. Pure. */ +export function missingChange(type: string, remote: number, prune: boolean): ImageChange { + return { type, change: !remote ? 'none' : prune ? 'delete' : 'keep', remote, local: 0 }; +} + +/** What one language uploads: the sets with files, the optional types without, and errors (missing required sets). */ +export interface LangImages { sets: [string, string[]][]; missing: string[]; errors: string[] } + +/** The image sets of one language folder (`tablets` = resolved play.tablets). Reads the folder only. */ +export function langImages(code: string, dir: string, icon: string, tablets: boolean): LangImages { + const out: LangImages = { sets: [], missing: [], errors: [] }; + if (existsSync(icon)) out.sets.push(['icon', [icon]]); + else out.errors.push(`${code}: no icon at ${icon} (512x512, required; make it: storebound icons, or set play.icon)`); + const feature = ['jpg', 'jpeg', 'png'].map(e => join(dir, `feature/1024x500.${e}`)).find(existsSync); + if (feature) out.sets.push(['featureGraphic', [feature]]); + else out.errors.push(`${code}: no featureGraphic in ${join(dir, 'feature')} (1024x500, required; render first: storebound shots render)`); + for (const [type, sub] of Object.entries(PLAY_IMAGE_DIRS)) { + const tablet = PLAY_TABLET_TYPES.includes(type); + if (tablet && !tablets) { out.missing.push(type); continue; } + const files = numberedImages(join(dir, sub)).slice(0, 8); + if (!files.length) { + if (type === 'phoneScreenshots') out.errors.push(`${code}: no phoneScreenshots in ${join(dir, sub)} (at least 2, required; render first: storebound shots render)`); + else if (tablet) out.errors.push(`${code}: play.tablets is true but there are no ${type} in ${join(dir, sub)} (render them: storebound shots render --size tab7,tab10, or set play.tablets: false)`); + else out.missing.push(type); + continue; + } + if ((type === 'phoneScreenshots' || tablet) && files.length < 2) out.errors.push(`${code}: ${type} needs at least 2 images (${files.length} in ${join(dir, sub)})`); + out.sets.push([type, files]); + } + return out; +} + +/** Info lines for the optional sets no language has locally. Pure. */ +export function missingInfo(missing: ReadonlySet): string[] { + const out: string[] = []; + if (PLAY_TABLET_TYPES.some(t => missing.has(t))) out.push("tablet screenshots: none (optional; the app isn't eligible for tablet featuring without them)"); + if (missing.has('tvScreenshots') && missing.has('wearScreenshots')) out.push('TV / Wear screenshots: none (optional; only for Android TV / Wear OS apps)'); + else for (const t of ['tvScreenshots', 'wearScreenshots']) if (missing.has(t)) out.push(`${t}: none (optional)`); + return out; +} + +/** One language's plan as text: "would replace a (1 -> 2), b (0 -> 2); would delete c (...); keeps d (...)". Pure. */ +export function describePlan(plan: ImageChange[]): string { + const of = (k: ImageChange['change']) => plan.filter(c => c.change === k); + const parts = [ + of('replace').length ? 'would replace ' + of('replace').map(c => `${c.type} (${c.remote} -> ${c.local})`).join(', ') : '', + of('delete').length ? 'would delete ' + of('delete').map(c => `${c.type} (${c.remote} on Play, none locally; --prune)`).join(', ') : '', + ].filter(Boolean); + if (!parts.length) parts.push(of('keep').length ? 'nothing to upload' : 'unchanged'); + for (const c of of('keep')) parts.push(`keeps ${c.type} on Play (${c.remote}, none locally; --prune deletes them)`); + return parts.join('; '); +} + const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex'); export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const pkg = need(config.play?.packageName, 'play.packageName'); + const prune = ctx.args.has('prune'); const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants); if (ctx.args.get('variant')) log('play', `variant ${ctx.args.get('variant')}: ${src}`); const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png')); const langOf = byPlayCode(config.languages); const codes = readPlayCopy(ctx).map(c => c.lang); + const tablets = resolvedTablets(config, src); - const sets = new Map(); + const sets = new Map(); + const errors: string[] = []; for (const code of codes) { const l = langOf.get(code); if (!l) throw new Error(`${code}: no entry in languages[] has this Play code`); - const dir = join(src, l.id); - const feature = ['jpg', 'jpeg', 'png'].map(e => join(dir, `feature/1024x500.${e}`)).find(existsSync); - const set: [string, string[]][] = [['icon', [icon]], ['featureGraphic', feature ? [feature] : []], - ...Object.entries(PLAY_IMAGE_DIRS).map(([type, sub]) => [type, numberedImages(join(dir, sub)).slice(0, 8)] as [string, string[]])]; - for (const [type, files] of set) { - if (!files.length || !files.every(existsSync)) throw new Error(`${code}: no ${type} in ${dir} (render first: storebound shots render)`); - if (type.endsWith('Screenshots') && files.length < 2) throw new Error(`${code}: ${type} needs at least 2 images`); - } - sets.set(code, set); + const li = langImages(code, join(src, l.id), icon, tablets); + errors.push(...li.errors); + sets.set(code, li); } - log('play', `${codes.length} languages, ${[...sets.values()][0]?.reduce((n, [, f]) => n + f.length, 0) ?? 0} images each`); + if (errors.length) throw new Error(errors.join('\n')); + const first = [...sets.values()][0]; + log('play', `${codes.length} languages: ${first?.sets.map(([t, f]) => `${t} (${f.length})`).join(', ') ?? 'nothing'}`); + for (const line of missingInfo(new Set(OPTIONAL_TYPES.filter(t => [...sets.values()].every(s => s.missing.includes(t)))))) log('play', line); + if (config.play?.tablets === false) log('play', 'play.tablets is false: tablet screenshots are not uploaded'); const play = await playClient(root, pkg); - // one read-only edit: what Play has now (sha256 per image, in order) against the local files, per language and type + const remoteOf = async (id: string, code: string, type: string) => + (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? []; + // one read-only edit: what Play has now (sha256 per image, in order) against the local files, per language and type; + // for an optional type with no local files only how many Play has (kept, or deleted with --prune) const plan = new Map(); await play.read(async id => { for (const code of codes) { - const out: ImageChange[] = []; - for (const [type, files] of sets.get(code)!) { - const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? []; - out.push(imageChange(type, remote, files.map(sha256))); - } + const li = sets.get(code)!, out: ImageChange[] = []; + for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(sha256))); + for (const type of li.missing) out.push(missingChange(type, (await remoteOf(id, code, type)).length, prune)); plan.set(code, out); } }); - const changedTypes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace'); + const writes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace' || c.change === 'delete'); if (ctx.args.has('dry')) { let changed = 0; for (const [i, code] of codes.entries()) { - const rep = changedTypes(code); - changed += rep.length; - log('play', `${step(i + 1, codes.length, code)}: ${rep.length ? 'would replace ' + rep.map(c => `${c.type} (${c.remote} -> ${c.local})`).join(', ') : 'unchanged'}`); + changed += writes(code).length; + log('play', `${step(i + 1, codes.length, code)}: ${describePlan(plan.get(code)!)}`); } - done('play', `dry run: ${changed ? changed + ' image set(s) would be replaced' : 'every image set is already on Play'}; read-only, nothing uploaded`); + done('play', `dry run: ${changed ? changed + ' image set(s) would change' : 'every image set is already on Play'}; read-only, nothing uploaded`); return; } - // only languages with a changed image type get an edit, and in it only the changed types are cleared and uploaded. - // The edit is all or nothing: a failure (or an upload without a clear answer) deletes it, and the redo starts from - // what Play has, so a re-run never doubles an image. - const todo = codes.filter(code => changedTypes(code).length); + for (const code of codes) for (const c of plan.get(code)!.filter(x => x.change === 'keep')) + log('play', `${code}: keeps ${c.type} on Play (${c.remote}, none locally; --prune deletes them)`); + // only languages with a changed image type get an edit, and in it only the changed types are cleared and uploaded + // (with --prune also the optional types that have no local files are cleared). The edit is all or nothing: a failure + // (or an upload without a clear answer) deletes it, and the redo starts from what Play has, so a re-run never doubles + // an image. + const todo = codes.filter(code => writes(code).length); if (!todo.length) { done('play', 'every image set is already on Play: nothing uploaded'); return; } for (const [i, code] of todo.entries()) { - const set = sets.get(code)!; + const li = sets.get(code)!; await play.edit(async (id, ctl) => { - let n = 0, types = 0; - await Promise.all(set.map(async ([type, files]) => { - const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? []; - if (imageChange(type, remote, files.map(sha256)).change === 'same') return; - types++; - if (remote.length) await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`); - for (const f of files) { - await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${code}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), - { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); - n++; - } - })); + let n = 0, types = 0, deleted = 0; + await Promise.all([ + ...li.sets.map(async ([type, files]) => { + const remote = await remoteOf(id, code, type); + if (imageChange(type, remote, files.map(sha256)).change === 'same') return; + types++; + if (remote.length) await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`); + for (const f of files) { + await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${code}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), + { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); + n++; + } + }), + ...(prune ? li.missing.map(async type => { + if (!(await remoteOf(id, code, type)).length) return; + types++; deleted++; + await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`); + }) : []), + ]); if (!types) ctl.unchanged(); - log('play', `${step(i + 1, todo.length, code)}: ${n} images in ${types} set(s)`); + log('play', `${step(i + 1, todo.length, code)}: ${n} images in ${types - deleted} set(s)${deleted ? `, ${deleted} set(s) deleted (--prune)` : ''}`); }); } done('play', 'images done'); diff --git a/cli/shots/check.ts b/cli/shots/check.ts index 17dc66c..cc45379 100644 --- a/cli/shots/check.ts +++ b/cli/shots/check.ts @@ -3,7 +3,8 @@ // Per image: pixel size for its store type (Apple: a listed size, portrait or landscape; Play screenshots: 320-3840 px, // at most 2:1; feature graphic exactly 1024x500), no alpha (both stores), Play: JPEG or 24-bit PNG, 8 MB per screenshot. // --variants also checks the A/B variant folders (///...). -// Per set: Play 2-8 phone screenshots, Apple 1-10 per display type, 13" iPad set when apple.ipad, feature graphic. +// Per set: Play 2-8 phone screenshots, Apple 1-10 per display type, 13" iPad set when apple.ipad, feature graphic, +// Play 7" / 10" tablet sets only when play.tablets (default: tablet renders exist; optional on Play). // Per app preview: 886x1920 / 1200x1600, H.264, <= 30 fps, 15-30 s, stereo AAC track, <= 500 MB, <= 3 per set. // Text overflow flags come from the last `shots render` (report.json). Frames: with capture.source "folder" every frame // the scenes use must exist with the right aspect. Local font files (shots.fonts paths) without a license note: warn. @@ -16,43 +17,45 @@ import { log, printJson } from '../lib/log'; import { checkFrames, type FrameIssue } from './devices'; import { defaultSizes, frameDims, SIZES } from './lib'; import type { LocalIssue, RendersCheck } from '../lib/validate-rules'; -import { allIssues, requiredSets, type RendersScan, scanRenders, storeOf } from './report'; +import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders, storeOf } from './report'; import { imageInfo } from './spec'; import { fontsDirOf, readManifest } from './fonts'; import { localFontIssues } from './local-fonts'; /** Frame files the scenes (and the feature graphic) use, per language and device class. */ -export function framesNeeded(config: ShipConfig, langs: string[]): { lang: string; device: 'phone' | 'tablet'; name: string }[] { +export function framesNeeded(config: ShipConfig, langs: string[], tablets?: boolean): { lang: string; device: 'phone' | 'tablet'; name: string }[] { const shots = config.shots!; - const sizes = defaultSizes(config); + const sizes = defaultSizes(config, tablets); const devices: ('phone' | 'tablet')[] = ['phone', ...(sizes.some(s => SIZES[s]!.device === 'tablet') ? ['tablet' as const] : [])]; const scene = [...new Set(shots.scenes.flatMap(s => s.frames.map(f => f.src)))]; const phone = [...new Set([...scene, ...(shots.feature?.frame && sizes.includes('feature') ? [shots.feature.frame] : [])])]; return langs.flatMap(lang => devices.flatMap(device => (device === 'phone' ? phone : scene).map(name => ({ lang, device, name })))); } -export function checkFrameFolder(config: ShipConfig, dir: string, langs: string[]): FrameIssue[] { +export function checkFrameFolder(config: ShipConfig, dir: string, langs: string[], tablets?: boolean): FrameIssue[] { const found = new Map(); for (const lang of langs) for (const device of ['phone', 'tablet']) { const d = join(dir, lang, device); if (!existsSync(d)) continue; for (const f of readdirSync(d).filter(x => /\.png$/i.test(x))) { const i = imageInfo(readFileSync(join(d, f))); found.set(`${lang}/${device}/${f}`, i); } } - return checkFrames(found, framesNeeded(config, langs), frameDims(config)); + return checkFrames(found, framesNeeded(config, langs, tablets), frameDims(config)); } export function scanProject(ctx: Ctx): RendersScan { const { config, resolve } = ctx.loaded; const langs = ctx.args.list('lang').length ? ctx.args.list('lang') : config.languages.map(l => l.id); const dir = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); - return scanRenders(dir, { langs, variants: ctx.args.has('variants') ? Object.keys(config.shots?.variants ?? {}) : [], required: lang => requiredSets(config, lang) }); + const tablets = resolvedTablets(config, dir); + return scanRenders(dir, { langs, variants: ctx.args.has('variants') ? Object.keys(config.shots?.variants ?? {}) : [], required: lang => requiredSets(config, lang, tablets) }); } /** Local renders check split per store, for `validate` (base renders only, no variants). null = no renders folder. */ export function rendersCheck(config: ShipConfig, dir: string): Record<'apple' | 'play', RendersCheck> | null { if (!existsSync(dir)) return null; const langs = config.languages.map(l => l.id); - const scan = scanRenders(dir, { langs, required: lang => requiredSets(config, lang) }); + const tablets = resolvedTablets(config, dir); + const scan = scanRenders(dir, { langs, required: lang => requiredSets(config, lang, tablets) }); const out = { apple: { dir, images: 0, issues: [] as LocalIssue[] }, play: { dir, images: 0, issues: [] as LocalIssue[] } }; for (const img of scan.images) out[storeOf(img.rel)].images++; for (const v of scan.videos) out[storeOf(v.rel)].images++; @@ -65,7 +68,8 @@ export async function run(ctx: Ctx): Promise { const scan = scanProject(ctx); const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue }))]; const cap = config.shots?.capture; - const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs) : []; + const tablets = resolvedTablets(config, resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders')); + const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs, tablets) : []; if (ctx.args.has('json')) { printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues }); } else { diff --git a/cli/shots/gallery.ts b/cli/shots/gallery.ts index 4a13c21..b476179 100644 --- a/cli/shots/gallery.ts +++ b/cli/shots/gallery.ts @@ -11,7 +11,7 @@ import { join, relative } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { SIZES } from './lib'; -import { allIssues, requiredSets, type RendersScan, scanRenders } from './report'; +import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders } from './report'; import { type Issue, IMAGE_SPECS, VIDEO_TARGETS } from './spec'; export interface GalleryTile { @@ -241,7 +241,8 @@ export async function run(ctx: Ctx): Promise { const dir = resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); const out = a.get('out') ? resolve(a.get('out')!) : join(dir, 'index.html'); const variants = a.has('no-variants') ? [] : Object.keys(config.shots?.variants ?? {}); - const scan = scanRenders(dir, { langs, variants, required: lang => requiredSets(config, lang) }); + const tablets = resolvedTablets(config, dir); + const scan = scanRenders(dir, { langs, variants, required: lang => requiredSets(config, lang, tablets) }); if (!scan.images.length && !scan.videos.length) warn('shots', `no renders in ${dir} (storebound shots render)`); const data = galleryData(scan, { at: join(out, '..'), title: `${config.app.name} · store renders`, exists: existsSync }); // --json: the data only (the desktop gallery), with the renders folder relative to the app folder; writes nothing diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index 55d0547..eb62560 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -116,12 +116,18 @@ export function frameDims(config: Pick): Record<' return { phone: { ...DEFAULT_PHONE, ...config.shots?.phone }, tablet: { ...tablet, ...config.shots?.tablet } }; } +/** Play tablet size keys (7" and 10"): optional on Play, switched by play.tablets. */ +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), - * Play sizes only with a play section (both when neither is configured). */ -export function defaultSizes(config: Pick): string[] { + * 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, tablets?: boolean): string[] { const apple = !!config.apple || !config.play, play = !!config.play || !config.apple; - return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium : k.startsWith('ios') ? apple : play)); + 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('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play)); } /** Scenes of a variant (its order / subset, palette overrides), or the base scenes. */ diff --git a/cli/shots/render.ts b/cli/shots/render.ts index 557bf85..78a9545 100644 --- a/cli/shots/render.ts +++ b/cli/shots/render.ts @@ -4,7 +4,8 @@ // Out: ///NN.png (24-bit RGB: Play rejects alpha / palette PNGs), /feature/1024x500.png, // /sheet.png (contact sheet of every size packed into a grid, for a quick review) and /report.json (text overflow flags // per image, read by `shots check` and `shots gallery`). Variants go to ///... -// Default sizes: defaultSizes() (ipad13 only with apple.ipad). Needs playwright + ffmpeg. +// Default sizes: defaultSizes() (ipad13 only with apple.ipad; tab7 / tab10 only with play.tablets, default: tablet renders +// already exist). Needs playwright + ffmpeg. // Fonts: the per-language stacks from .storebound/fonts/fonts.json (`shots fonts`). Before rendering, every character // of the copy is checked against the cmaps of the downloaded faces (stale fonts fail with the language and the // characters); while rendering, the page measures which face drew every character, and report.json records it per @@ -25,7 +26,7 @@ import { defaultConcurrency, pool, progressLine } from './capture-web'; import { shotsStyle } from '../lib/config'; import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib'; import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts'; -import { type RenderReport, mergeReport, readReport } from './report'; +import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report'; const TYPES: Record = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css', svg: 'image/svg+xml' }; @@ -35,7 +36,10 @@ export async function run(ctx: Ctx): Promise { const shots = need(config.shots, 'shots'); const a = ctx.args; const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id); - const sizes = a.list('size').length ? a.list('size') : defaultSizes(config); + const tablets = resolvedTablets(config, resolve(shots.out)); + const sizes = a.list('size').length ? a.list('size') : defaultSizes(config, tablets); + if (config.play && !tablets && !a.list('size').length) + log('shots', `Play tablet screenshots: not rendered (${config.play.tablets === false ? 'play.tablets is false' : 'no tablet renders yet; set play.tablets: true to render tab7 / tab10'}; optional on Play)`); for (const s of sizes) if (!SIZES[s]) throw new Error(`unknown size ${s} (${Object.keys(SIZES).join(', ')})`); const variants: (string | undefined)[] = a.has('variants') ? [undefined, ...Object.keys(shots.variants ?? {})] : [a.get('variant')]; const baseCopy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile; diff --git a/cli/shots/report.ts b/cli/shots/report.ts index 8910cd5..3d8db0f 100644 --- a/cli/shots/report.ts +++ b/cli/shots/report.ts @@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { SIZES } from './lib'; +import { PLAY_TABLET_SIZES, SIZES } from './lib'; import { fontIssues, type FontUsage } from './glyphs'; import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec'; @@ -86,13 +86,29 @@ export function ffprobe(file: string): ProbeInfo | null { } /** Which sets the stores require for this config (missing ones are errors, others only checked when present). With a - * language id: Apple sets only when that language has an App Store locale, Play sets only when it has a Play code. */ + * language id: Apple sets only when that language has an App Store locale, Play sets only when it has a Play code. + * Play tablets (tab7, tab10) only when `tablets` (resolved play.tablets; unset here = play.tablets === true). */ export function requiredSets(c: { apple?: { ipad?: boolean; iphoneMedium?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] }, - lang?: string): Record { + lang?: string, tablets?: boolean): Record { const l = lang === undefined ? undefined : c.languages?.find(x => x.id === lang); const apple = !!c.apple && (!l || !!l.apple); const play = !!c.play && (!l || (Array.isArray(l.play) ? l.play.length > 0 : !!l.play)); - return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, play, feature: play }; + const tabs = play && (tablets ?? (c.play as { tablets?: boolean } | undefined)?.tablets === true); + return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, play, feature: play, + ...(tabs ? { tab7: true, tab10: true } : {}) }; +} + +/** Whether a language (or variant/language) folder under the renders dir has Play tablet renders (tab7/ or tab10/ with + * NN.png|jpg), for the play.tablets default. Reads the folder tree two levels deep. */ +export function tabletRendersExist(dir: string): boolean { + const sub = (d: string): string[] => { try { return readdirSync(d, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => join(d, e.name)); } catch { return []; } }; + const has = (d: string) => PLAY_TABLET_SIZES.some(s => existsSync(join(d, s)) && readdirSync(join(d, s)).some(f => /^\d+\.(png|jpe?g)$/i.test(f))); + return sub(dir).some(l => has(l) || sub(l).some(has)); +} + +/** play.tablets for a project, with the default (tablet renders exist under `rendersDir`) filled in. */ +export function resolvedTablets(config: { play?: { tablets?: boolean } }, rendersDir: string): boolean { + return config.play?.tablets ?? tabletRendersExist(rendersDir); } export const allIssues = (s: RendersScan): { where: string; issue: Issue }[] => [ diff --git a/cli/validate.ts b/cli/validate.ts index d695c1a..101be07 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -23,8 +23,9 @@ import { checkUrl } from './lib/http'; import { collectHealth } from './health'; import { healthFindings } from './lib/health'; import { log, printJson, warn } from './lib/log'; -import { type AppleSnapshot, appleRules, type Finding, formatFindings, PLAY_REQUIRED_IMAGES, type PlaySnapshot, playRules, type ProfileFacts, rendersFindings, sortFindings } from './lib/validate-rules'; +import { type AppleSnapshot, appleRules, type Finding, formatFindings, type PlaySnapshot, playRequiredImages, playRules, type ProfileFacts, rendersFindings, sortFindings } from './lib/validate-rules'; import { rendersCheck } from './shots/check'; +import { resolvedTablets } from './shots/report'; import { runScan } from './lib/scan'; import type { ScanFinding } from './lib/scan/rules'; import { playClient } from './play/client'; @@ -115,6 +116,7 @@ export async function collectApple(ctx: Ctx): Promise { export async function collectPlay(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const play = await playClient(root, config.play!.packageName); + const tablets = resolvedTablets(config, ctx.loaded.resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders')); const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {}); log('validate', `play: read-only edit ${e.id} (deleted afterwards)`); try { @@ -123,7 +125,7 @@ export async function collectPlay(ctx: Ctx): Promise { const listings: PlaySnapshot['listings'] = []; for (const l of raw) { const images: Record = {}; - for (const type of Object.keys(PLAY_REQUIRED_IMAGES)) + for (const type of Object.keys(playRequiredImages(tablets))) images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${e.id}/listings/${l.language}/${type}`)).images ?? []).length; listings.push({ ...l, images }); } @@ -148,7 +150,7 @@ export async function collectPlay(ctx: Ctx): Promise { } while (page); } return { - subscriptions, expectedSubscriptions: wantSubs.map(({ sub }) => ({ id: sub.id, basePlanId: basePlanId(sub) })), + tablets, subscriptions, expectedSubscriptions: wantSubs.map(({ sub }) => ({ id: sub.id, basePlanId: basePlanId(sub) })), profile: await collectProfile(ctx.loaded), defaultLanguage: details.defaultLanguage ?? config.play?.defaultLanguage ?? 'en-US', expectedLanguages: config.languages.flatMap(playCodes), listings, tracks, products, diff --git a/docs/pitfalls.md b/docs/pitfalls.md index e58d4c1..a0fd65f 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 19 | 2026-10 | `play images --dry` aborted with "en-US: no sevenInchScreenshots ... (render first)" on a phone-only project: Play's 7" and 10" tablet screenshots are optional (required: 512 icon, 1024x500 feature graphic, at least 2 screenshots; Play Console Help answer 9866151), but every image type was treated as required. | `play images` uploads what exists (icon, feature graphic, phone screenshots, tablet / TV / Wear only when rendered), prints an info line for missing optional sets, never deletes what Play has for them without `--prune`; `play.tablets` (default: tablet renders exist) switches tablets for `shots render` / `shots check` / `shots gallery` / `validate` / `play images`, and `true` with missing renders is an error (`cli/play/images.ts` `langImages`, `resolvedTablets` in `cli/shots/report.ts`, `tests/play-post-reads.test.ts`). | | 18 | 2026-10 | `console fill play-app-record` with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered `/v3/signin/rejected` and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. | Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no `--enable-automation`, `navigator.webdriver` false) and attached over CDP; `--use-chrome-session google\|apple` is an explicit opt-in that warns; `/signin/rejected` is said plainly (`cli/console/session.ts`, `tests/console-session.test.ts`). | | 17 | 2026-10 | After the data types were saved, the real App Privacy page shows each type's "Set Up " as a plain `

` in a div with a click handler (no button role, no link) and puts the types in a box after the Data Types heading's box: role-based locators and the "nearest heading" check found nothing, so no type was set up. A "?" (More information) button sits next to each type. | `setUpAllowed` / `setUpTexts` (`cli/lib/console-forms.ts`: exact text "Set Up " of a type storebound answers, in the Data Types section, outside dialogs) and `setUpClick` / `setUpType` / `flowWhere` (last heading before the control) in `cli/console/fill.ts`; the per-type dialog steps are `page` sections, so an unknown step dumps and pauses instead of ending the run; `tests/fixtures/console/apple-app-privacy-setup.html` (structure of the real page) and `tests/console-flow.test.ts`. | | 16 | 2026-10 | `console fill play-app-record` with the copied Chrome cookies printed only "still waiting for the sign-in" for 5+ minutes on a signed-in Play Console: Google pages embed `accounts.google.com` frames (the account widget, cookie rotation), and the detector counted any frame on a sign-in host. | `signInReason`: a sign-in page only by its top-level URL, a visible password / email-entry form in the main frame, or a visible Apple sign-in frame (idmsa / appleid); other frames never count. Each detection logs "sign-in detected because: at "; heartbeats name the page; a copied session that lands on a sign-in URL is said plainly ("did not carry over"); the watchdog dumps the sign-in wait once after `--step-timeout`, `--debug-dump` up to 3 times (`tests/console-fill.test.ts`, chooser / home with the widget frames, a top-level accounts.google.com page). | diff --git a/skills/play-first-release/SKILL.md b/skills/play-first-release/SKILL.md index 4e16873..811b063 100644 --- a/skills/play-first-release/SKILL.md +++ b/skills/play-first-release/SKILL.md @@ -43,6 +43,10 @@ deleting products. Sending a first release for review is the owner's action in t `--include-drafts` only on the owner's word); the real run asks in a terminal and PUTs only what changes. 7. **API · Graphics.** Render (`store-screenshots` skill), `storebound shots jpeg`, then `storebound play images > .storebound/play-images.log 2>&1 &` and follow the log (it is slow: one edit per language). + Required: the 512 x 512 icon, the 1024 x 500 feature graphic and at least 2 phone screenshots (Play Console Help + answer 9866151). 7" / 10" tablet screenshots are optional (no tablet featuring without them): a phone-only project + gets an info line, not an error; `play.tablets: true` renders and requires them, `false` never asks. Tablet + screenshots already on Play stay unless the owner runs `--prune`. 8. **API · In-app products.** `store-iap-setup` skill (`storebound play products`). Needs a build with the BILLING permission on any track first. 9. **API · Data safety.** Answers in `dataSafety` of the config, `storebound play data-safety`, then check the summary diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 9ff4e7e..9d37c8d 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -190,6 +190,22 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful https://developer.chrome.com/docs/web-platform/device-bound-session-credentials , https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies +## 19. `play images` aborts on missing tablet screenshots +- **Symptom:** `storebound play images --dry` on a phone-only project (renders with feature, ios65, ios69 and play, no + tab7 / tab10) stopped with `en-US: no sevenInchScreenshots in .../store/renders/en (render first: storebound shots + render)`. +- **Cause:** every Play image type was treated as required. Play requires only the 512 x 512 icon, the 1024 x 500 + feature graphic and at least 2 screenshots (Play Console Help, "Add preview assets to showcase your app", answer + 9866151); 7" / 10" tablet, TV and Wear screenshots are optional (without tablet screenshots the app is not eligible + for tablet featuring). +- **Do:** nothing special: `play images` uploads what exists and prints `tablet screenshots: none (optional; ...)`. To + render and require tablets set `play.tablets: true`; to never ask for them, `play.tablets: false` (default: true + when tablet renders exist). A missing optional set never deletes the tablet screenshots Play already has; the dry run + shows `keeps sevenInchScreenshots on Play`, and only `--prune` (owner's call) deletes them. +- **Guard:** `langImages` / `missingChange` in `cli/play/images.ts`, `resolvedTablets` in `cli/shots/report.ts`; + `tests/play-post-reads.test.ts` (phone-only fixture: dry plan, info line, exit 0; `tablets: true` without renders is + an error; no tablet DELETE without `--prune`). + ## Adding a lesson Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test that fails without the fix (see CONTRIBUTING.md). diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index 1f7cc14..0f4433c 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -16,8 +16,8 @@ iOS Simulator capture needs macOS + Xcode (`xcrun`); Android capture needs `adb` | ios65 | 1284 x 2778 | App Store 6.5" (`APP_IPHONE_65`) | | 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) | -| tab10 | 1440 x 2560 | Play 10" tablet | +| tab7 | 1080 x 1920 | Play 7" tablet (tablet frames, Android tablet body); optional, see `play.tablets` | +| tab10 | 1440 x 2560 | Play 10" tablet; optional, see `play.tablets` | | feature | 1024 x 500 | Play feature graphic (required) | Sources (re-check before a release; Apple renames display classes often): @@ -42,6 +42,13 @@ 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. +**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 +false; `false` = `shots render`, `shots check`, `shots gallery`, `validate` and `play images` never ask for them; +`true` = they are rendered, and missing ones are errors. A phone-only project needs no setting: `play images` uploads +the icon, feature graphic and phone screenshots and prints `tablet screenshots: none (optional; ...)`. + ## Steps 1. **Screenshot switches.** Give the app or game params for screenshots: language, demo data or a fixed seed, no ads / analytics / purchase UI, skip onboarding or the tutorial, jump to a screen or level, freeze at a moment. Frames must @@ -119,6 +126,8 @@ in a plain Android tablet body instead of the iPad one. only issues, search) and light / dark. Tiles with store check errors are outlined red, warnings amber, with the message under the tile. Show the owner this page (or a screenshot of it) before uploading. 10. **Upload.** `storebound play images` and `storebound apple screenshots` (both idempotent, background + log). + `play images` uploads only the sets that exist (tablet / TV / Wear when rendered) and never deletes a set Play has + but the renders lack: the dry run shows `keeps on Play`; `--prune` (only on the owner's word) deletes them. ## A/B variants and store experiments `shots.variants.`: `scenes` (order / subset), `copy` (a JSON merged over the base copy, only what changes), diff --git a/tests/play-post-reads.test.ts b/tests/play-post-reads.test.ts index e5cc4c5..2d5d8bf 100644 --- a/tests/play-post-reads.test.ts +++ b/tests/play-post-reads.test.ts @@ -10,14 +10,17 @@ // existing product, even when a run would change a regional price or replace the listings. It names the changes. import { afterEach, describe, expect, test } from 'bun:test'; import { createHash, generateKeyPairSync } from 'node:crypto'; -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { collectPlayHealth } from '../cli/health'; import { parseArgs } from '../cli/lib/args'; import type { Loaded, ShipConfig } from '../cli/lib/config'; import { healthFindings, healthTable, issuesQuery, metricQuery } from '../cli/lib/health'; -import { imageChange, run as imagesRun } from '../cli/play/images'; +import { imageChange, missingChange, missingInfo, run as imagesRun } from '../cli/play/images'; +import { defaultSizes } from '../cli/shots/lib'; +import { requiredSets, resolvedTablets } from '../cli/shots/report'; +import { playRequiredImages } from '../cli/lib/validate-rules'; import { detailsChanges, listingChanges, run as listingRun } from '../cli/play/listing'; import { type Existing, productChanges, run as productsRun } from '../cli/play/products'; import { toMoney } from '../cli/lib/money'; @@ -171,7 +174,7 @@ describe('play images --dry: hashes compared, nothing uploaded', () => { const calls = fake(c => { if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-2' }); const m = c.url.match(/\/edits\/edit-2\/listings\/en-US\/(\w+)$/); - if (c.method === 'GET' && m) return json(remote[m[1]!]!.length ? { images: remote[m[1]!]!.map(s => image(sha(s))) } : {}); + if (c.method === 'GET' && m) return json(remote[m[1]!]?.length ? { images: remote[m[1]!]!.map(s => image(sha(s))) } : {}); if (c.method === 'DELETE' && c.url === `${API}/edits/edit-2`) return new Response(null, { status: 204 }); return json({ error: { code: 400, message: 'unexpected' } }, 400); }); @@ -186,6 +189,117 @@ describe('play images --dry: hashes compared, nothing uploaded', () => { }); }); +describe('play images: tablet, TV and Wear sets are optional (play.tablets)', () => { + const sha = (s: string) => createHash('sha256').update(s).digest('hex'); + const LISTING_MD = '## English\n\n\n### Play short description\n```\nShort.\n```\n\n### Play full description\n```\nFull.\n```\n'; + /** A phone-only project like STAR DASH: renders/en has feature, ios65, ios69 and play; no tab7 / tab10. */ + function phoneOnly(play: Record = {}): Loaded { + const root = mkdtempSync(join(tmpdir(), 'sb-tabs-')); + const files: Record = { 'store/icons/play-512.png': 'icon', 'store/renders/en/feature/1024x500.png': 'feature', 'store/listing.md': LISTING_MD }; + for (const sub of ['play', 'ios65', 'ios69']) for (const n of ['01', '02']) files[`store/renders/en/${sub}/${n}.png`] = `${sub}${n}`; + for (const [p, body] of Object.entries(files)) { mkdirSync(join(root, p, '..'), { recursive: true }); writeFileSync(join(root, p), body); } + const config = { app: { name: 'Example Game' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG, ...play } } as unknown as ShipConfig; + return { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; + } + /** Play double: images per type (Play already has 7" tablet screenshots), edits, uploads, commit; records calls. */ + function store(remote: Record) { + return fake(c => { + if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-3' }); + const m = c.url.match(/\/edits\/edit-3\/listings\/en-US\/(\w+)(\?.*)?$/); + if (c.method === 'GET' && m) return json(remote[m[1]!]?.length ? { images: remote[m[1]!]!.map(s => image(sha(s))) } : {}); + if (c.method === 'DELETE' && m) { remote[m[1]!] = []; return json({ deleted: [] }); } + if (c.method === 'POST' && m) return json({ image: image(sha('x')) }); + if (c.method === 'POST' && c.url.endsWith(':commit')) return json({ id: 'edit-3' }); + if (c.method === 'DELETE' && c.url === `${API}/edits/edit-3`) return new Response(null, { status: 204 }); + return json({ error: { code: 400, message: `unexpected ${c.method} ${c.url}` } }, 400); + }); + } + async function capture(fn: () => Promise): Promise { + const lines: string[] = []; + const log = console.log; + console.log = (...a: unknown[]) => { lines.push(a.join(' ')); }; + try { await fn(); } finally { console.log = log; } + return lines.join('\n'); + } + + test('phone-only renders: the dry plan lists icon, feature graphic and phone screenshots, an info line for tablets, exit 0', async () => { + playKey(); + const loaded = phoneOnly(); + const calls = store({ sevenInchScreenshots: ['old701', 'old702'] }); + const before = process.exitCode; + const out = await capture(() => imagesRun({ loaded, args: parseArgs(['--dry']) })); + expect(process.exitCode).toBe(before); + expect(out).toContain('1 languages: icon (1), featureGraphic (1), phoneScreenshots (2)'); + expect(out).toContain("tablet screenshots: none (optional; the app isn't eligible for tablet featuring without them)"); + expect(out).toContain('TV / Wear screenshots: none (optional; only for Android TV / Wear OS apps)'); + expect(out).toContain('en-US: would replace icon (0 -> 1), featureGraphic (0 -> 1), phoneScreenshots (0 -> 2); keeps sevenInchScreenshots on Play (2, none locally; --prune deletes them)'); + expect(calls.every(readOnly)).toBe(true); + }); + + test('--dry --prune shows the tablet set Play has as a delete; still read-only', async () => { + playKey(); + const calls = store({ icon: ['icon'], featureGraphic: ['feature'], phoneScreenshots: ['play01', 'play02'], sevenInchScreenshots: ['old701'] }); + const out = await capture(() => imagesRun({ loaded: phoneOnly(), args: parseArgs(['--dry', '--prune']) })); + expect(out).toContain('en-US: would delete sevenInchScreenshots (1 on Play, none locally; --prune)'); + expect(calls.every(readOnly)).toBe(true); + }); + + test('a real run uploads the phone sets and never deletes the tablet screenshots on Play without --prune', async () => { + playKey(); + const remote: Record = { sevenInchScreenshots: ['old701', 'old702'] }; + const calls = store(remote); + await capture(() => imagesRun({ loaded: phoneOnly(), args: parseArgs([]) })); + const writes = calls.filter(c => !readOnly(c)).map(c => `${c.method} ${c.url.replace(API, '').replace(/^.*\/upload\/androidpublisher\/v3\/applications\/[^/]+/, 'UPLOAD').replace(/\?.*$/, '')}`); + expect(writes.filter(w => w.startsWith('POST UPLOAD'))).toHaveLength(4); + expect(writes.some(w => w.includes('sevenInch') || w.includes('tenInch'))).toBe(false); + expect(writes.at(-1)).toBe('POST /edits/edit-3:commit'); + expect(remote.sevenInchScreenshots).toHaveLength(2); + }); + + test('play.tablets: true with no tablet renders is a clear error before any store call', async () => { + playKey(); + const calls = store({}); + await expect(imagesRun({ loaded: phoneOnly({ tablets: true }), args: parseArgs(['--dry']) })) + .rejects.toThrow(/en-US: play.tablets is true but there are no sevenInchScreenshots in .*tab7 \(render them: storebound shots render --size tab7,tab10, or set play.tablets: false\)/); + expect(calls).toEqual([]); + }); + + test('a missing required set is still an error with the fix', async () => { + playKey(); + const loaded = phoneOnly(); + rmSync(join(loaded.root, 'store/renders/en/play'), { recursive: true }); + store({}); + await expect(imagesRun({ loaded, args: parseArgs(['--dry']) })).rejects.toThrow(/en-US: no phoneScreenshots in .*play \(at least 2, required; render first: storebound shots render\)/); + }); + + test('play.tablets: false skips tablet renders that exist; the default follows the renders', async () => { + playKey(); + const loaded = phoneOnly({ tablets: false }); + for (const n of ['01', '02']) { mkdirSync(join(loaded.root, 'store/renders/en/tab7'), { recursive: true }); writeFileSync(join(loaded.root, `store/renders/en/tab7/${n}.png`), `t${n}`); } + store({}); + const out = await capture(() => imagesRun({ loaded, args: parseArgs(['--dry']) })); + expect(out).toContain('play.tablets is false: tablet screenshots are not uploaded'); + expect(out).not.toContain('sevenInchScreenshots (0 -> 2)'); + expect(resolvedTablets({ play: {} }, join(loaded.root, 'store/renders'))).toBe(true); + expect(resolvedTablets({ play: { tablets: false } }, join(loaded.root, 'store/renders'))).toBe(false); + expect(resolvedTablets({ play: {} }, join(phoneOnly().root, 'store/renders'))).toBe(false); + }); + + test('pure parts: missing optional sets, required sets and render sizes follow play.tablets', () => { + expect(missingChange('tenInchScreenshots', 0, false).change).toBe('none'); + expect(missingChange('tenInchScreenshots', 3, false).change).toBe('keep'); + expect(missingChange('tenInchScreenshots', 3, true).change).toBe('delete'); + expect(missingInfo(new Set(['tvScreenshots']))).toEqual(['tvScreenshots: none (optional)']); + const cfg = { play: { packageName: PKG } }; + expect(requiredSets(cfg).tab7).toBeUndefined(); + expect(requiredSets(cfg, undefined, true)).toMatchObject({ tab7: true, tab10: true }); + expect(defaultSizes(cfg as Pick, false)).toEqual(['play', 'feature']); + expect(defaultSizes(cfg as Pick, true)).toEqual(['play', 'tab7', 'tab10', 'feature']); + expect(playRequiredImages(false)).toEqual({ icon: 1, featureGraphic: 1, phoneScreenshots: 2 }); + expect(Object.keys(playRequiredImages(true))).toContain('tenInchScreenshots'); + }); +}); + describe('play products --dry: what would change on an existing product', () => { // one product of oneTimeProducts.list, as answered (3 of 174 regions; Money without nanos for whole prices) const CUR: Existing = { productId: 'com.example.game.no_ads',