play images: tablet, TV and Wear sets are optional; play.tablets switch (default: tablet renders exist); a missing optional set never deletes Play's images without --prune
A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
This commit is contained in:
parent
18fde37b9b
commit
a9261a8052
18 changed files with 364 additions and 83 deletions
11
CHANGELOG.md
11
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 <type> 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
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, 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 <id>'],
|
||||
'play images': [() => import('./play/images'), 'icon, feature graphic, phone screenshots per language, optional tablet / TV / Wear sets when rendered (play.tablets); --variant <id>, --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'],
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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_<number>" (Play Console >
|
||||
|
|
|
|||
|
|
@ -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<string, number> = { 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<string, number> = { sevenInchScreenshots: 1, tenInchScreenshots: 1 };
|
||||
/** Image types validate requires on Play for a project. */
|
||||
export const playRequiredImages = (tablets: boolean | undefined): Record<string, number> => (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`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 <renders>/<language id>/: 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 <id> uploads <renders>/<id>/<lang>/... (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 <renders>/<language id>/: 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 <id> uploads <renders>/<id>/<lang>/... (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>): 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<void> {
|
||||
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<string, [string, string[]][]>();
|
||||
const sets = new Map<string, LangImages>();
|
||||
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<string, ImageChange[]>();
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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 (<renders>/<variant>/<lang>/...).
|
||||
// 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<string, { w: number; h: number }>();
|
||||
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<void> {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -116,12 +116,18 @@ export function frameDims(config: Pick<ShipConfig, 'apple' | 'shots'>): 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<ShipConfig, 'apple' | 'play'>): 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<ShipConfig, 'apple' | 'play'>, 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. */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
// Out: <shots.out>/<lang>/<size>/NN.png (24-bit RGB: Play rejects alpha / palette PNGs), <lang>/feature/1024x500.png,
|
||||
// <lang>/sheet.png (contact sheet of every size packed into a grid, for a quick review) and <shots.out>/report.json (text overflow flags
|
||||
// per image, read by `shots check` and `shots gallery`). Variants go to <shots.out>/<variant>/<lang>/...
|
||||
// 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<string, string> = { 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<void> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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<string, boolean> {
|
||||
lang?: string, tablets?: boolean): Record<string, boolean> {
|
||||
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 }[] => [
|
||||
|
|
|
|||
|
|
@ -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<AppleSnapshot> {
|
|||
export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
|
||||
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<PlaySnapshot> {
|
|||
const listings: PlaySnapshot['listings'] = [];
|
||||
for (const l of raw) {
|
||||
const images: Record<string, number> = {};
|
||||
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<PlaySnapshot> {
|
|||
} 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,
|
||||
|
|
|
|||
|
|
@ -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 <type>" as a plain `<p>` 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 <type>" 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: <rule> at <url>"; 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). |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 <type> on Play`; `--prune` (only on the owner's word) deletes them.
|
||||
|
||||
## A/B variants and store experiments
|
||||
`shots.variants.<id>`: `scenes` (order / subset), `copy` (a JSON merged over the base copy, only what changes),
|
||||
|
|
|
|||
|
|
@ -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<!-- play: en-US -->\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<string, unknown> = {}): Loaded {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sb-tabs-'));
|
||||
const files: Record<string, string> = { '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<string, string[]>) {
|
||||
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<void>): Promise<string> {
|
||||
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<string, string[]> = { 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<ShipConfig, 'apple' | 'play'>, false)).toEqual(['play', 'feature']);
|
||||
expect(defaultSizes(cfg as Pick<ShipConfig, 'apple' | 'play'>, 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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue