store art from the live stores: storebound art pull (App Store screenshot sets and previews per locale and display type from imageAsset.templateUrl at the original size, the App Store icon from the newest build or the public artwork; per Play language icon, feature graphic, phone / 7" / 10" / TV / Wear screenshots as the =s0 originals checked against Play's sha256) into store/renders with pulled.json (sha256, store, URL host, store checksum); upsert (a re-run changes nothing, differing local files kept unless --force, the upload's file of a slot compared: Play .jpg, App Store .png, the App Store icon as a picture); onboard runs it; apple screenshots / previews and play images see unchanged pulled files as the store's own; check: render sources are notes with pulled art; local renders check: pulled files pass, store-empty sets not demanded; art live / thumb / replace; desktop Store art tab: live thumbnails per store, language and set (same / differs / not pulled), Pull from stores, store-only apps read-only, Replace with a file (image_pick, art.replace) in the lightbox and the art editor; boot check store art case; tests/art-pull.test.ts; console parity 76 rows (57 complete, 19 with the reason); store-screenshots skill (pull first for live apps), pitfall 59

This commit is contained in:
CRTkafa 2026-10-14 02:18:38 +08:00
parent 49a73c90e4
commit 00be838b76
40 changed files with 1669 additions and 58 deletions

View file

@ -34,11 +34,11 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing listing-store onboard validate',
'appInfos_getInstance': 'apple/appinfo',
'appInfos_updateInstance': 'apple/appinfo',
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews',
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews art',
'appPreviewSets_createInstance': 'apple/previews',
'appPreviews_updateInstance': 'apple/previews',
'appPriceSchedules_createInstance': 'apple/appinfo',
'appScreenshotSets_appScreenshots_getToManyRelated': 'apple/ppo apple/screenshots validate',
'appScreenshotSets_appScreenshots_getToManyRelated': 'apple/ppo apple/screenshots art validate',
'appScreenshotSets_createInstance': 'apple/screenshots',
'appStoreReviewDetails_createInstance': 'apple/review-details',
'appStoreReviewDetails_updateInstance': 'apple/review-details',
@ -51,15 +51,15 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'appStoreVersionExperimentsV2_createInstance': 'lib/ppo',
'appStoreVersionExperimentsV2_getInstance': 'apple/ppo',
'appStoreVersionExperimentsV2_updateInstance': 'lib/ppo',
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews',
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/screenshots validate',
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews art',
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/screenshots art validate',
'appStoreVersionLocalizations_createInstance': 'listing-store',
'appStoreVersionLocalizations_updateInstance': 'listing-store',
'appStoreVersionPhasedReleases_createInstance': 'apple/release',
'appStoreVersionPhasedReleases_updateInstance': 'apple/release',
'appStoreVersionReleaseRequests_createInstance': 'apple/release',
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details onboard validate',
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots listing-store onboard validate',
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots art listing-store onboard validate',
'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status',
'appStoreVersions_build_getToOneRelated': 'apple/listing apple/resubmit validate',
'appStoreVersions_build_updateToOneRelationship': 'apple/listing apple/resubmit',
@ -74,7 +74,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'apps_appPricePoints_getToManyRelated': 'apple/appinfo',
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status lib/store-apps listing-store onboard validate',
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status art lib/store-apps listing-store onboard validate',
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
@ -117,7 +117,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'builds_betaAppReviewSubmission_getToOneRelated': 'apple/testflight',
'builds_betaBuildLocalizations_getToManyRelated': 'apple/testflight',
'builds_diagnosticSignatures_getToManyRelated': 'health',
'builds_getCollection': 'apple/build apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload health lib/store-apps triage',
'builds_getCollection': 'apple/build apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload art health lib/store-apps triage',
'builds_updateInstance': 'apple/encryption lib/build-ops',
'bundleIdCapabilities_createInstance': 'lib/signing',
'bundleIds_createInstance': 'apple/bundle',
@ -218,10 +218,10 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'androidpublisher.edits.details.get': 'apps onboard play/listing play/status validate',
'androidpublisher.edits.details.update': 'play/listing',
'androidpublisher.edits.images.deleteall': 'play/images',
'androidpublisher.edits.images.list': 'apps play/images validate',
'androidpublisher.edits.images.list': 'apps art play/images validate',
'androidpublisher.edits.images.upload': 'play/images',
'androidpublisher.edits.insert': 'apps onboard play/client validate',
'androidpublisher.edits.listings.list': 'listing-store play/listing play/status validate',
'androidpublisher.edits.listings.list': 'art listing-store play/listing play/status validate',
'androidpublisher.edits.listings.patch': 'listing-store',
'androidpublisher.edits.listings.update': 'listing-store play/listing',
'androidpublisher.edits.tracks.get': 'play/release play/rollout play/upload',

View file

@ -17,6 +17,7 @@ import { ffprobe } from '../shots/report';
import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from '../shots/spec';
import { timeCode } from '../shots/video';
import { findOrCreate } from '../lib/upsert';
import { pulledChecksum } from '../lib/art-pull';
import { ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
/** Video target folder -> ASC previewType, for this project (iPad only with apple.ipad). */
@ -89,7 +90,7 @@ export async function run(ctx: Ctx): Promise<void> {
const tag = `${locale} ${type}`;
let set: J = existingSets.find(s => s.attributes.previewType === type);
const existing: J[] = set ? await c.all(`/v1/appPreviewSets/${set.id}/appPreviews`) : [];
const want = fs.map(f => md5(readFileSync(f)));
const want = fs.map(f => pulledChecksum(f, 'apple') ?? md5(readFileSync(f)));
const state = (p: J) => p.attributes.videoDeliveryState?.state ?? p.attributes.assetDeliveryState?.state;
const sameFiles = setUpToDate(existing.map(p => ({ checksum: p.attributes.sourceFileChecksum, state: state(p) })), want);
if (sameFiles) {

View file

@ -15,6 +15,7 @@ import { done, log, warn } from '../lib/log';
import { numberedImages } from '../play/images';
import { variantRoot } from '../shots/lib';
import { findOrCreate } from '../lib/upsert';
import { pulledChecksum } from '../lib/art-pull';
import { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
@ -41,7 +42,8 @@ export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?:
export async function syncScreenshotSet(c: Asc, parent: { rel: string; type: string; id: string }, sets: J[], type: string, files: string[], tag: string, dry: boolean): Promise<void> {
let set = sets.find(s => s.attributes.screenshotDisplayType === type);
const existing = set ? await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`) : [];
const want = files.map(f => md5(readFileSync(f)));
// a file still exactly as `art pull` saved it is the store's own: compare the store's checksum of the original
const want = files.map(f => pulledChecksum(f, 'apple') ?? md5(readFileSync(f)));
const state = (s: J) => s.attributes.assetDeliveryState?.state;
if (set && setUpToDate(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: state(s) })), want)) { log('apple', `${tag}: ${files.length} up to date`); return; }
if (dry) {

519
cli/art.ts Normal file
View file

@ -0,0 +1,519 @@
// Store art that is already live: pull it into the project instead of rendering it again, look at it, replace one file.
// storebound art pull [--store apple|play] [--write] [--force] [--dry] [--json]
// Every App Store screenshot set and app preview per locale and display type (of the version being prepared, else
// the live one), the App Store icon (the newest build's icon at 1024 px, else the public lookup artwork), and per
// Play language the icon, feature graphic and phone / 7" / 10" / TV / Wear screenshots (the original files, =s0).
// Saved in the store art layout (lib/art-pull.ts): store/renders/<lang>/<size>/NN.png, feature/1024x500.*, the
// icons at app-store-1024.png / play.icon, with <renders>/pulled.json (sha256 of each file, store, URL host, when).
// check / validate then treat them like rendered art, and `play images` / `apple screenshots` see "same as the
// store" and upload nothing. Without --write it only prints the plan (nothing is downloaded but an icon to compare).
// An upsert: a re-run changes nothing; a local file that differs from the store (your own render, an edit after the
// pull) is never overwritten without --force (listed as a conflict). Read-only toward the stores (GETs, one Play
// read edit that is deleted). No personal data: the manifest keeps the URL host, never the URL.
// storebound art live [--store apple|play] [--json] the live art per store, language and set with thumbnail
// URLs and whether the project has the same file (the desktop's live view; store-only apps too)
// storebound art thumb --url <https://...mzstatic.com|googleusercontent.com/...> --json one thumbnail as a data:
// URL (at most 2 MB; the desktop window loads no remote images itself)
// storebound art replace --from <png|jpg file> --to <path in the renders or frames folder> [--dry] [--json]
// replaces one image of the app folder with a file the owner picked (the dry run shows old -> new and the checks)
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path';
import { ascClient, type J } from './apple/asc';
import { playCodes, type ShipConfig } from './lib/config';
import type { Ctx } from './lib/ctx';
import { done, log, printJson, warn } from './lib/log';
import { textTable } from './lib/table';
import type { LiveArtResult, LiveArtSet, LiveArtTile } from './lib/desktop-protocol';
import {
appleDir, ART_HOST_RE, type ArtStep, type ArtStore, decide, emptyPulled, extraStep, fillTemplate, hostOf, type LocalFile, md5Of, PLAY_SHOT_DIR, PLAY_TYPES, playUrl,
posix, type Pulled, PULLED_FILE, readPulled, type RemoteArt, sha256Of, slotOf, sniffExt, storeEmptySets, summarize, thumbSize,
} from './lib/art-pull';
import { playClient } from './play/client';
import { decodePng, hashGray, imageSimilarity } from './lib/imagehash';
import { IMAGE_SPECS, checkImage, imageInfo } from './shots/spec';
const TAG = 'art';
const FETCH_TIMEOUT_MS = 120_000;
const JOBS = 6;
/** Runs jobs `n` at a time. */
async function pool<T>(items: T[], n: number, fn: (x: T) => Promise<void>): Promise<void> {
let next = 0;
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (next < items.length) await fn(items[next++]!); }));
}
/** GET bytes from Apple's / Google's image CDN, with a timeout and two retries. */
export async function download(url: string, max = 600 * 1024 * 1024): Promise<Uint8Array> {
if (!ART_HOST_RE.test(url)) throw new Error(`not a store image host: ${hostOf(url)}`);
let last: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { 'User-Agent': 'storebound (art pull)' } });
if (!r.ok) throw new Error(`${hostOf(url)}: HTTP ${r.status}`);
const b = new Uint8Array(await r.arrayBuffer());
if (b.length > max) throw new Error(`${hostOf(url)}: ${b.length} bytes, more than ${max}`);
return b;
} catch (e) { last = e; if (/HTTP 4\d\d|more than/.test(String(e))) break; }
}
throw last;
}
// ---------------------------------------------------------------------------------------------------------------------
// Reading the stores (GETs only)
export interface StoreRead { items: RemoteArt[]; version?: string | null; notes: string[] }
/** App Store art: the version being prepared (else the live one, else the newest): every screenshot set and preview
* set per locale, plus the icon of the newest build (else the public lookup artwork). */
export async function readApple(root: string, config: ShipConfig): Promise<StoreRead> {
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const appId = await c.app();
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
const state = (v: J) => v.attributes.appStoreState ?? v.attributes.appVersionState;
const v: J = (await c.editableVersion()) ?? versions.find((x: J) => state(x) === 'READY_FOR_SALE') ?? versions[0] ?? null;
const out: StoreRead = { items: [], version: v ? `${v.attributes.versionString} (${state(v)})` : null, notes: [] };
if (v) {
const locs = await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`);
await pool(locs, 4, async (loc: J) => {
const locale: string = loc.attributes.locale;
for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets?limit=50`)) {
const type: string = set.attributes.screenshotDisplayType;
const shots = await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`);
shots.forEach((s: J, index: number) => {
const a = s.attributes ?? {}, img = a.imageAsset;
if (!img?.templateUrl || !img.width || !img.height) { out.notes.push(`${locale} ${type} ${index + 1}: no image yet (${a.assetDeliveryState?.state ?? 'processing'})`); return; }
const [tw, th] = thumbSize(img.width, img.height);
out.items.push({ store: 'apple', kind: 'screenshot', type, locale, index, url: fillTemplate(img.templateUrl, img.width, img.height, 'png'),
thumb: fillTemplate(img.templateUrl, tw, th, 'jpg'), w: img.width, h: img.height, checksum: a.sourceFileChecksum ?? null, id: s.id });
});
}
for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appPreviewSets?limit=50`)) {
const type: string = set.attributes.previewType;
const previews = await c.all(`/v1/appPreviewSets/${set.id}/appPreviews?limit=50`);
previews.forEach((p: J, index: number) => {
const a = p.attributes ?? {}, img = a.previewImage;
if (!a.videoUrl) { out.notes.push(`${locale} preview ${type} ${index + 1}: no video URL yet (${a.assetDeliveryState?.state ?? 'processing'})`); return; }
const [tw, th] = img?.width ? thumbSize(img.width, img.height) : [300, 300];
out.items.push({ store: 'apple', kind: 'preview', type, locale, index, url: a.videoUrl, thumb: img?.templateUrl ? fillTemplate(img.templateUrl, tw, th, 'jpg') : '',
w: img?.width, h: img?.height, checksum: a.sourceFileChecksum ?? null, id: p.id });
});
}
});
} else out.notes.push('no App Store version yet: no screenshots to pull');
// the icon: the newest build's (the one in the binary), else the public artwork of the live app
const builds = await c.all(`/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1&fields[builds]=iconAssetToken,version`).catch(() => [] as J[]);
const tok = builds[0]?.attributes?.iconAssetToken;
if (tok?.templateUrl) {
out.items.push({ store: 'apple', kind: 'icon', type: 'icon', locale: '', index: 0, url: fillTemplate(tok.templateUrl, 1024, 1024, 'png'), thumb: fillTemplate(tok.templateUrl, 256, 256, 'jpg'),
w: 1024, h: 1024, checksum: null, id: `build-${builds[0].attributes.version}-${sha256Of(new TextEncoder().encode(tok.templateUrl)).slice(0, 16)}` });
} else {
const art = await lookupArtwork(config.apple!.bundleId);
if (art) out.items.push({ store: 'apple', kind: 'icon', type: 'icon', locale: '', index: 0, url: art.replace(/\/\d+x\d+bb\.(png|jpg)$/, '/1024x1024bb.png'),
thumb: art.replace(/\/\d+x\d+bb\.(png|jpg)$/, '/256x256bb.jpg'), w: 1024, h: 1024, checksum: null, id: `lookup-${sha256Of(new TextEncoder().encode(art)).slice(0, 16)}` });
else out.notes.push('App Store icon: no build with an icon and not on the public store yet');
}
return out;
}
/** The public App Store artwork URL of a bundle id (iTunes lookup API, no credentials), or null. */
async function lookupArtwork(bundleId: string): Promise<string | null> {
try {
const r = await fetch(`https://itunes.apple.com/lookup?bundleId=${encodeURIComponent(bundleId)}`, { signal: AbortSignal.timeout(20_000) });
const j = await r.json() as { results?: { artworkUrl512?: string; artworkUrl100?: string }[] };
const u = j.results?.[0]?.artworkUrl512 ?? j.results?.[0]?.artworkUrl100;
return u && ART_HOST_RE.test(u) ? u : null;
} catch { return null; }
}
/** Play art: per listing language every image type (one read-only edit, deleted afterwards). */
export async function readPlay(root: string, config: ShipConfig): Promise<StoreRead> {
const play = await playClient(root, config.play!.packageName);
const out: StoreRead = { items: [], notes: [] };
await play.read(async id => {
const ls = await play.call<{ listings?: { language: string }[] }>('GET', `${play.API}/edits/${id}/listings`);
const jobs = (ls.listings ?? []).flatMap(l => PLAY_TYPES.map(type => ({ code: l.language, type })));
await pool(jobs, 4, async ({ code, type }) => {
const r = await play.call<{ images?: { id: string; url: string; sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`);
(r.images ?? []).forEach((img, index) => {
out.items.push({ store: 'play', kind: type === 'icon' ? 'icon' : type === 'featureGraphic' ? 'featureGraphic' : 'screenshot', type, locale: code, index,
url: playUrl(img.url, 'original'), thumb: playUrl(img.url, type === 'featureGraphic' ? 400 : 300), checksum: img.sha256 ?? null, id: img.id });
});
});
});
return out;
}
// ---------------------------------------------------------------------------------------------------------------------
// The plan
export interface ArtPaths { root: string; renders: string; rendersRel: string; appleIcon: string; playIcon: string }
export function artPaths(ctx: Ctx): ArtPaths {
const { config, root, resolve } = ctx.loaded;
const renders = resolve(config.renders ?? config.shots?.out ?? 'store/renders');
const icons = config.icons?.out ?? 'store/icons';
return { root, renders, rendersRel: posix(relative(root, renders)), appleIcon: posix(join(icons, 'app-store-1024.png')), playIcon: posix(config.play?.icon ?? join(icons, 'play-512.png')) };
}
/** Store locale -> config language id, and the preference of a store locale for its language (lower first): a Play
* language with several codes (es-419, es-ES) takes the first code of the config that has the image. */
export function languageMap(config: ShipConfig) {
const apple = new Map<string, { id: string; rank: number }>(), play = new Map<string, { id: string; rank: number }>();
config.languages.forEach((l, i) => {
if (l.apple) apple.set(l.apple, { id: l.id, rank: i * 100 });
playCodes(l).forEach((code, j) => { if (!play.has(code)) play.set(code, { id: l.id, rank: i * 100 + j }); });
});
return { of: (store: ArtStore, locale: string) => (store === 'apple' ? apple : play).get(locale) ?? null };
}
const EXTS = ['png', 'jpg', 'jpeg', 'mp4', 'mov', 'm4v'];
/** The local file in a slot (any extension), with its hashes. The file the upload sends wins: Play takes a .jpg next to
* a .png of the same number (play images), the App Store the .png (apple screenshots). */
function localIn(root: string, slotRel: string, store: ArtStore = 'apple'): LocalFile | null {
for (const e of store === 'play' ? ['jpg', 'jpeg', 'png', 'mp4', 'mov', 'm4v'] : EXTS) {
const f = join(root, `${slotRel}.${e}`);
if (existsSync(f)) { const b = readFileSync(f); return { rel: `${slotRel}.${e}`, sha256: sha256Of(b), md5: md5Of(b) }; }
}
return null;
}
export interface ArtPlan { steps: ArtStep[]; unmapped: string[]; storeEmpty: string[]; filled: Set<string>; notes: string[] }
/** The upsert plan: each live image against its slot (pure apart from reading the local files). */
export function planArt(p: ArtPaths, config: ShipConfig, remote: RemoteArt[], pulled: Pulled | null, force: boolean): ArtPlan {
const lm = languageMap(config);
const notes: string[] = [];
const unmapped = new Set<string>();
// one live image per slot: the preferred store locale of the language
const best = new Map<string, { r: RemoteArt; rank: number; slot: ReturnType<typeof slotOf> & object }>();
const ctx = { renders: p.rendersRel, appleIcon: p.appleIcon, playIcon: p.playIcon, langOf: (s: ArtStore, l: string) => lm.of(s, l)?.id ?? null };
for (const r of remote) {
const slot = slotOf(r, ctx);
if (!slot) { if (r.locale) unmapped.add(`${r.store === 'apple' ? 'App Store' : 'Play'} ${r.locale}`); continue; }
// the Play icon: the default (first) language's; the App Store icon is one
const rank = r.kind === 'icon' && r.store === 'apple' ? 0 : lm.of(r.store, r.locale)?.rank ?? 1e9;
const key = `${slot.rel}`;
const cur = best.get(key);
// a set comes whole from one locale: compare by the locale's rank, then keep the first seen
if (!cur || rank < cur.rank) best.set(key, { r, rank, slot });
}
// a set must come from one locale only (es-419 vs es-ES): keep, per <lang>/<size>, the best-ranked locale's items
const setLocale = new Map<string, { locale: string; rank: number }>();
for (const { r, rank, slot } of best.values()) {
if (r.kind === 'icon') continue;
const k = `${r.store}|${slot.lang}/${slot.size}`;
const cur = setLocale.get(k);
if (!cur || rank < cur.rank) setLocale.set(k, { locale: r.locale, rank });
}
const steps: ArtStep[] = [];
const filled = new Set<string>();
const planned = new Set<string>();
const counts = new Map<string, number>();
for (const { r, slot } of [...best.values()].sort((a, b) => a.slot.rel.localeCompare(b.slot.rel))) {
if (r.kind !== 'icon' && setLocale.get(`${r.store}|${slot.lang}/${slot.size}`)?.locale !== r.locale) continue;
const local = localIn(p.root, slot.rel, r.store);
const entry = local && pulled ? pulled.files[posix(relative(p.renders, join(p.root, local.rel)))] ?? null : null;
steps.push(decide(r, slot.rel, local, entry, force, slot.ext));
planned.add(slot.rel);
if (r.kind !== 'icon') { filled.add(`${slot.lang}/${slot.size}`); counts.set(dirname(slot.rel), Math.max(counts.get(dirname(slot.rel)) ?? 0, r.index + 1)); }
}
// local files of a pulled set beyond the store's count, and earlier pulls no longer on the store
for (const [dirRel] of counts) {
const d = join(p.root, dirRel);
if (!existsSync(d)) continue;
for (const f of readdirSync(d).filter(x => /^\d+\.(png|jpe?g|mp4|mov|m4v)$/i.test(x)).sort()) {
const slotRel = `${dirRel}/${f.replace(/\.[^.]+$/, '')}`;
if (planned.has(slotRel)) continue;
const b = readFileSync(join(d, f));
const lf = { rel: `${dirRel}/${f}`, sha256: sha256Of(b), md5: md5Of(b) };
steps.push(extraStep(lf, pulled?.files[posix(relative(p.renders, join(d, f)))] ?? null, force));
planned.add(slotRel);
}
}
for (const [rel, e] of Object.entries(pulled?.files ?? {})) {
const abs = join(p.renders, rel);
const slotRel = posix(relative(p.root, abs)).replace(/\.[^.]+$/, '');
if (planned.has(slotRel) || !existsSync(abs)) continue;
// a store whose read failed keeps its files
if (!remote.some(r => r.store === e.store)) continue;
const b = readFileSync(abs);
steps.push(extraStep({ rel: posix(relative(p.root, abs)), sha256: sha256Of(b), md5: md5Of(b) }, e, force));
}
// every set size of the store (not only today's required ones: Play tablets become required once any language has them)
const sizesOf = (store: ArtStore) => Object.keys(IMAGE_SPECS).filter(s => IMAGE_SPECS[s]!.store === store);
const stores = new Set(remote.map(r => r.store));
const storeEmpty = storeEmptySets(config.languages.map(l => ({ id: l.id, apple: !!l.apple && !!config.apple && stores.has('apple'), play: playCodes(l).length > 0 && !!config.play && stores.has('play') })),
filled, { apple: sizesOf('apple'), play: sizesOf('play') });
return { steps, unmapped: [...unmapped].sort(), storeEmpty, filled, notes };
}
// ---------------------------------------------------------------------------------------------------------------------
// pull
export interface PullReport {
stores: { store: ArtStore; ok: boolean; version?: string | null; images: number; error?: string }[];
summary: ReturnType<typeof summarize>;
steps: { action: string; file: string; why: string; store?: string; type?: string; locale?: string }[];
conflicts: string[];
unmapped: string[];
storeEmpty: string[];
notes: string[];
written: boolean;
manifest: string;
}
/** Downloads a preview: an .mp4 / .mov file straight, an HLS stream through ffmpeg (copy, no re-encode). */
async function downloadPreview(url: string, out: string): Promise<void> {
if (!/\.m3u8(\?|$)/i.test(url)) { writeFileSync(out, await download(url)); return; }
if (spawnSync('sh', ['-c', 'command -v ffmpeg']).status !== 0) throw new Error('the preview is a stream (HLS): ffmpeg is needed to save it (storebound doctor)');
const r = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', url, '-c', 'copy', '-movflags', '+faststart', out], { timeout: 300_000 });
if (r.status !== 0) throw new Error(`ffmpeg: ${String(r.stderr).slice(0, 300)}`);
}
/** Two PNGs showing the same picture (perceptual hashes nearly equal; sizes may differ). False when one does not decode. */
export function samePicture(a: Uint8Array, b: Uint8Array): boolean {
try { return imageSimilarity(hashGray(decodePng(Buffer.from(a))), hashGray(decodePng(Buffer.from(b)))) >= 0.97; } catch { return false; }
}
/** `art pull` as a function (onboard runs it too). Writes only with `write`. */
export async function artPull(ctx: Ctx, o: { store?: string; write: boolean; force: boolean; quiet?: boolean }): Promise<PullReport> {
const { config, root } = ctx.loaded;
const p = artPaths(ctx);
const report: PullReport = { stores: [], summary: { add: 0, same: 0, update: 0, conflict: 0, remove: 0 }, steps: [], conflicts: [], unmapped: [], storeEmpty: [], notes: [], written: false, manifest: posix(relative(root, join(p.renders, PULLED_FILE))) };
const remote: RemoteArt[] = [];
const say = (m: string) => { if (!o.quiet) log(TAG, m); };
for (const store of ['apple', 'play'] as const) {
if (o.store && o.store !== store) continue;
if (!config[store]) continue;
say(`reading the ${store === 'apple' ? 'App Store' : 'Google Play'} art`);
try {
const r = store === 'apple' ? await readApple(root, config) : await readPlay(root, config);
remote.push(...r.items);
report.notes.push(...r.notes);
report.stores.push({ store, ok: true, version: r.version, images: r.items.length });
} catch (e) {
report.stores.push({ store, ok: false, images: 0, error: (e as Error).message.slice(0, 300) });
warn(TAG, `${store}: ${(e as Error).message.slice(0, 300)}`);
}
}
const pulled = readPulled(p.renders);
// an icon without a store checksum (the App Store one): compare the bytes when a local file is there
const iconBytes = new Map<RemoteArt, Uint8Array>();
for (const r of remote.filter(x => x.checksum === null && x.kind === 'icon')) {
const slot = slotOf(r, { renders: p.rendersRel, appleIcon: p.appleIcon, playIcon: p.playIcon, langOf: () => null });
const local = slot && localIn(root, slot.rel, r.store);
if (!local) continue;
try {
const b = await download(r.url);
iconBytes.set(r, b);
// Apple re-encodes the icon of the build: the same picture is never the same bytes, so compare the pictures
if (sha256Of(b) === local.sha256 || samePicture(b, readFileSync(join(root, local.rel)))) r.checksum = local.md5;
} catch (e) { report.notes.push(`icon: ${(e as Error).message}`); }
}
const plan = planArt(p, config, remote, pulled, o.force);
report.unmapped = plan.unmapped;
report.storeEmpty = plan.storeEmpty;
report.summary = summarize(plan.steps);
report.steps = plan.steps.filter(s => s.action !== 'same').map(s => ({ action: s.action, file: s.rel, why: s.why, store: s.remote?.store, type: s.remote?.type, locale: s.remote?.locale }));
report.conflicts = plan.steps.filter(s => s.action === 'conflict').map(s => s.rel);
if (!o.write) return report;
const next: Pulled = pulled ? { ...pulled, files: { ...pulled.files } } : emptyPulled();
const now = new Date().toISOString();
const keyOf = (rel: string) => posix(relative(p.renders, join(root, rel)));
const entryFor = (s: ArtStep, rel: string, sha: string) => ({ store: s.remote!.store, kind: s.remote!.kind, type: s.remote!.type, locale: s.remote!.locale, host: hostOf(s.remote!.url),
sha256: sha, storeChecksum: s.remote!.checksum, storeId: s.remote!.id, pulledAt: now });
const failed: string[] = [];
await pool(plan.steps.filter(s => s.action === 'add' || s.action === 'update'), JOBS, async s => {
const r = s.remote!;
try {
const base = s.rel.replace(/\.[^.]+$/, '');
const tmp = join(root, `${base}.pulling`);
mkdirSync(dirname(tmp), { recursive: true });
if (r.kind === 'preview') await downloadPreview(r.url, tmp);
else writeFileSync(tmp, iconBytes.get(r) ?? await download(r.url));
const b = readFileSync(tmp);
if (r.store === 'play' && r.checksum && sha256Of(b) !== r.checksum) throw new Error('the download does not match the sha256 Play gives (retry)');
// the App Store icon has no store checksum: the bytes' MD5 stands in (a re-run compares the download with it)
if (r.checksum === null && r.kind === 'icon') r.checksum = md5Of(b);
const ext = sniffExt(b) ?? (r.kind === 'preview' ? 'mp4' : 'png');
if (r.kind !== 'preview' && ext !== 'png' && ext !== 'jpg') throw new Error(`not a PNG or JPEG (${ext})`);
const finalRel = `${base}.${r.kind === 'icon' && /\.png$/i.test(s.rel) && ext === 'png' ? 'png' : ext}`;
// the slot's old file with another extension goes (it is an unchanged pull, or --force said so)
for (const e of EXTS) { const old = `${base}.${e}`; if (old !== finalRel && existsSync(join(root, old))) { rmSync(join(root, old)); delete next.files[keyOf(old)]; } }
renameSync(tmp, join(root, finalRel));
next.files[keyOf(finalRel)] = entryFor(s, finalRel, sha256Of(b));
s.rel = finalRel;
} catch (e) {
failed.push(`${s.rel}: ${(e as Error).message.slice(0, 200)}`);
rmSync(join(root, `${s.rel.replace(/\.[^.]+$/, '')}.pulling`), { force: true });
}
});
for (const s of plan.steps) {
if (s.action === 'same' && s.adopt) next.files[keyOf(s.rel)] = entryFor(s, s.rel, sha256Of(readFileSync(join(root, s.rel))));
if (s.action === 'remove') { rmSync(join(root, s.rel), { force: true }); delete next.files[keyOf(s.rel)]; }
}
// entries whose file is gone
for (const k of Object.keys(next.files)) if (!existsSync(join(p.renders, k))) delete next.files[k];
next.storeEmpty = plan.storeEmpty;
next.files = Object.fromEntries(Object.entries(next.files).sort(([a], [b]) => a.localeCompare(b)));
const strip = (x: Pulled | null) => JSON.stringify(x ? { files: x.files, storeEmpty: x.storeEmpty } : null);
if (strip(next) !== strip(pulled)) {
next.pulledAt = now;
mkdirSync(p.renders, { recursive: true });
writeFileSync(join(p.renders, PULLED_FILE), JSON.stringify(next, null, 2) + '\n');
report.written = true;
}
if (failed.length) { report.notes.push(...failed.map(f => `failed: ${f}`)); process.exitCode = 1; }
return report;
}
/** Plan rows with one row per folder, action and reason ("store/renders/en/play/ 01-06"). Pure. */
export function groupSteps(steps: PullReport['steps']): string[][] {
const groups = new Map<string, { action: string; dir: string; why: string; names: string[] }>();
for (const s of steps) {
const i = s.file.lastIndexOf('/');
const dir = s.file.slice(0, i + 1), name = s.file.slice(i + 1);
const k = `${s.action}|${dir}|${s.why}`;
const g = groups.get(k) ?? { action: s.action, dir, why: s.why, names: [] };
g.names.push(name);
groups.set(k, g);
}
return [...groups.values()].map(g => [g.action, g.names.length === 1 ? g.dir + g.names[0] : `${g.dir} ${g.names.length} files (${g.names[0]} .. ${g.names[g.names.length - 1]})`, g.why]);
}
export function printPull(r: PullReport, write: boolean): void {
for (const s of r.stores) (s.ok ? log : warn)(TAG, s.ok ? `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.images} image(s)${s.version ? `, version ${s.version}` : ''}` : `${s.store}: could not read: ${s.error}`);
if (r.steps.length) console.log(textTable(['action', 'files', 'why'], groupSteps(r.steps)));
for (const u of r.unmapped) log(TAG, `${u}: no language in the config has this locale (not pulled)`);
for (const n of r.notes) warn(TAG, n);
if (r.conflicts.length) warn(TAG, `${r.conflicts.length} local file(s) differ from the store and are kept: ${r.conflicts.slice(0, 8).join(', ')}${r.conflicts.length > 8 ? ', ...' : ''} (--force replaces them with the store's)`);
const s = r.summary;
const line = `${s.add} new, ${s.update} changed, ${s.remove} removed, ${s.same} same as the store, ${s.conflict} kept (differ)`;
done(TAG, write ? `${line}${r.written ? `; ${r.manifest} updated` : '; nothing changed'}` : `dry run: would write ${line} (run with --write)`);
}
// ---------------------------------------------------------------------------------------------------------------------
// live / thumb / replace
type LiveTile = LiveArtTile;
type LiveSet = LiveArtSet;
type LiveResult = LiveArtResult;
const LABELS: Record<string, string> = {
APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'App Store iPhone 6.3"', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'App Store iPad 13"', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)',
IPHONE_67: 'App Store iPhone app preview', IPAD_PRO_3GEN_129: 'App Store iPad app preview', featureGraphic: 'Play feature graphic', phoneScreenshots: 'Play phone', sevenInchScreenshots: 'Play 7" tablet', tenInchScreenshots: 'Play 10" tablet', tvScreenshots: 'Play TV', wearScreenshots: 'Play Wear OS',
};
export async function artLive(ctx: Ctx, store?: string): Promise<LiveResult> {
const { config, root } = ctx.loaded;
// a store-only app (the desktop's generated config in a temp folder): no project files to compare with
const virtual = /storebound-virtual-/.test(root);
const out: LiveResult = { generated: new Date().toISOString(), stores: [], icons: [], sets: [], notes: [], pulled: false };
const remote: RemoteArt[] = [];
await Promise.all((['apple', 'play'] as const).filter(s => (!store || store === s) && config[s]).map(async s => {
try {
const r = s === 'apple' ? await readApple(root, config) : await readPlay(root, config);
remote.push(...r.items); out.notes.push(...r.notes);
out.stores.push({ store: s, ok: true, version: r.version, images: r.items.length });
} catch (e) { out.stores.push({ store: s, ok: false, images: 0, error: (e as Error).message.slice(0, 300) }); }
}));
out.stores.sort((a, b) => a.store.localeCompare(b.store));
const p = artPaths(ctx);
const pulled = virtual ? null : readPulled(p.renders);
out.pulled = !!pulled;
const steps = virtual ? [] : planArt(p, config, remote, pulled, false).steps;
const stateOf = (r: RemoteArt): { state: LiveTile['state']; file?: string } => {
if (virtual) return { state: 'n/a' };
const s = steps.find(x => x.remote === r);
if (!s) return { state: 'n/a' };
return s.action === 'add' ? { state: 'missing' } : s.action === 'same' ? { state: 'same', file: s.rel } : { state: 'differs', file: s.rel };
};
const lm = languageMap(config);
const sets = new Map<string, LiveSet>();
for (const r of remote.sort((a, b) => a.store.localeCompare(b.store) || a.locale.localeCompare(b.locale) || a.type.localeCompare(b.type) || a.index - b.index)) {
const tile: LiveTile = { index: r.index, type: r.type, w: r.w, h: r.h, thumb: r.thumb, ...stateOf(r) };
if (r.kind === 'icon') { if (!out.icons.some(i => i.store === r.store)) out.icons.push({ ...tile, store: r.store }); continue; }
const key = `${r.store}|${r.locale}|${r.type}`;
let set = sets.get(key);
if (!set) {
const size = r.store === 'apple' ? appleDir(r.kind, r.type) : r.kind === 'featureGraphic' ? 'feature' : PLAY_SHOT_DIR[r.type] ?? r.type;
set = { store: r.store, locale: r.locale, lang: lm.of(r.store, r.locale)?.id ?? null, kind: r.kind, type: r.type, size, label: LABELS[r.type] ?? r.type, tiles: [] };
sets.set(key, set);
}
set.tiles.push(tile);
}
out.sets = [...sets.values()];
return out;
}
const THUMB_MAX = 2 * 1024 * 1024;
/** The file `art replace` may write: inside the renders folder or the shots frames folder, a PNG / JPEG. */
export function replaceTarget(root: string, allowed: string[], to: string): string {
if (isAbsolute(to) || to.split(/[\\/]/).includes('..')) throw new Error('--to: a path relative to the app folder, without ..');
if (!/\.(png|jpe?g)$/i.test(to)) throw new Error('--to: a .png or .jpg file');
const abs = resolvePath(root, to);
if (!allowed.some(a => abs.startsWith(resolvePath(root, a) + '/'))) throw new Error(`--to: only files in ${allowed.join(' or ')} are replaced here`);
return abs;
}
export async function run(ctx: Ctx): Promise<void> {
const a = ctx.args;
const sub = a._[0] ?? 'pull';
const store = a.get('store');
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
if (sub === 'pull') {
const write = a.has('write') && !a.has('dry');
const r = await artPull(ctx, { store, write, force: a.has('force') });
if (a.has('json')) printJson(r); else printPull(r, write);
return;
}
if (sub === 'live') {
const r = await artLive(ctx, store);
if (a.has('json')) { printJson(r); return; }
for (const s of r.stores) (s.ok ? log : warn)(TAG, s.ok ? `${s.store}: ${s.images} image(s)${s.version ? `, version ${s.version}` : ''}` : `${s.store}: ${s.error}`);
console.log(textTable(['store', 'locale', 'lang', 'set', 'images', 'in the project'], r.sets.map(s => [s.store, s.locale, s.lang ?? '-', s.label, String(s.tiles.length),
s.tiles.every(t => t.state === 'same') ? 'same' : s.tiles.some(t => t.state === 'differs') ? 'differs' : s.tiles.every(t => t.state === 'missing') ? 'not pulled' : s.tiles[0]?.state === 'n/a' ? '-' : 'partly'])));
return;
}
if (sub === 'thumb') {
const url = a.get('url') ?? '';
if (!ART_HOST_RE.test(url)) throw new Error('--url: an https URL of the App Store or Google Play image CDN');
const b = await download(url, THUMB_MAX);
const ext = sniffExt(b);
if (ext !== 'png' && ext !== 'jpg') throw new Error('not an image');
printJson({ dataUrl: `data:image/${ext === 'jpg' ? 'jpeg' : 'png'};base64,${Buffer.from(b).toString('base64')}`, bytes: b.length });
return;
}
if (sub === 'replace') {
const { config, root } = ctx.loaded;
const from = a.get('from') ?? '';
if (!from || !isAbsolute(from) || !/\.(png|jpe?g)$/i.test(from) || !existsSync(from) || !statSync(from).isFile()) throw new Error('--from: an existing .png or .jpg file (absolute path)');
const p = artPaths(ctx);
const allowed = [p.rendersRel, ...(config.shots?.frames ? [config.shots.frames] : []), ...(config.shots?.capture?.folder ? [config.shots.capture.folder] : [])];
const to = a.get('to') ?? '';
const abs = replaceTarget(root, allowed, to);
const nb = readFileSync(from);
const ni = imageInfo(nb);
if (ni.format === 'unknown') throw new Error(`${from}: not a PNG or JPEG`);
if ((ni.format === 'png') !== /\.png$/i.test(to)) throw new Error(`--to ${to}: the picked file is a ${ni.format.toUpperCase()}; keep the format (or pick a ${/\.png$/i.test(to) ? 'PNG' : 'JPEG'})`);
const old = existsSync(abs) ? imageInfo(readFileSync(abs)) : null;
const rel = posix(relative(p.renders, abs));
const size = rel.startsWith('..') ? null : rel.split('/').find(x => IMAGE_SPECS[x]) ?? null;
const issues = size ? checkImage(ni, size) : [];
const res = { file: posix(relative(root, abs)), from: posix(from).split('/').pop(), old: old ? { w: old.w, h: old.h, bytes: old.bytes } : null, new: { w: ni.w, h: ni.h, bytes: ni.bytes }, issues, written: false };
const errors = issues.filter(i => i.level === 'error');
if (!a.has('dry') && !errors.length) { mkdirSync(dirname(abs), { recursive: true }); copyFileSync(from, abs); res.written = true; }
if (a.has('json')) printJson(res);
else {
log(TAG, `${res.file}: ${old ? `${old.w}x${old.h} -> ` : 'new, '}${ni.w}x${ni.h} (${res.from})`);
for (const i of issues) (i.level === 'error' ? warn : log)(TAG, `${i.level}: ${i.msg}`);
done(TAG, errors.length ? 'not replaced (the store would refuse it)' : res.written ? 'replaced (upload it with play images / apple screenshots after the dry run)' : 'dry run: would replace it');
}
if (errors.length) process.exitCode = 1;
return;
}
throw new Error('storebound art pull | live | thumb | replace');
}

View file

@ -25,6 +25,7 @@ import { detectProject } from './lib/scan/project';
import { reviewShotProblem } from './apple/iap-review';
import { allSubscriptions } from './lib/subscriptions';
import { type CopyFile, validateCopy } from './shots/lib';
import { posix, readPulled } from './lib/art-pull';
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
/** The newest .ipa in a folder (apple build's export), or null. */
@ -159,7 +160,18 @@ export async function run(ctx: Ctx): Promise<void> {
const bad = reviewShotProblem(readFileSync(file));
if (bad) problems.push(`products[].reviewScreenshot ${f}: ${bad}`);
}
if (config.shots) tryDo('shots copy', () => {
// store art pulled from the live stores (art pull): the render sources (screenshot captions, icon artwork, logo)
// are needed only to render again, so their gaps are notes, not problems
const pulled = readPulled(resolve(config.renders ?? config.shots?.out ?? 'store/renders'));
const fromStores = !!pulled && Object.keys(pulled.files).length > 0;
if (fromStores) log('check', `store art: ${Object.keys(pulled!.files).length} file(s) pulled from the stores (${posix(relative(ctx.loaded.root, resolve(config.renders ?? config.shots?.out ?? 'store/renders')))}/pulled.json); render sources are optional`);
if (config.shots && fromStores) {
try {
const copy = JSON.parse(readFileSync(resolve(config.shots.copy), 'utf8')) as CopyFile;
const e = validateCopy(copy, config.shots, config.languages.map(l => l.id));
if (e.length) note(`shots copy (only needed to render again; the store art is pulled): ${e.join('; ')}`);
} catch (e) { note(`shots copy (only needed to render again; the store art is pulled): ${(e as Error).message}`); }
} else if (config.shots) tryDo('shots copy', () => {
const copy = JSON.parse(readFileSync(resolve(config.shots!.copy), 'utf8')) as CopyFile;
const e = validateCopy(copy, config.shots!, config.languages.map(l => l.id));
if (e.length) throw new Error(e.join('; '));
@ -198,7 +210,11 @@ export async function run(ctx: Ctx): Promise<void> {
if (subs.length) log('check', `subscriptions: ${subs.length} in ${config.subscriptions!.length} group(s)`);
const files = [config.icons?.composite, config.icons?.foreground, config.icons?.background, config.shots?.logo,
...(config.products ?? []).map(p => p.reviewScreenshot), ...subs.map(x => x.sub.reviewScreenshot)].filter((f): f is string => !!f);
for (const f of new Set(files)) if (!existsSync(resolve(f))) problems.push(`missing file: ${f}`);
const renderSources = new Set([config.icons?.composite, config.icons?.foreground, config.icons?.background, config.shots?.logo].filter(Boolean));
for (const f of new Set(files)) if (!existsSync(resolve(f))) {
if (fromStores && renderSources.has(f)) note(`render source not there: ${f} (only needed to render icons / screenshots again; the store art is pulled)`);
else problems.push(`missing file: ${f}`);
}
if (existsSync(resolve('.gitignore')) && !/^\/?\.storebound\/?$/m.test(readFileSync(resolve('.gitignore'), 'utf8')))
problems.push('.gitignore does not ignore .storebound/ (credentials live there)');

View file

@ -31,6 +31,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'],
'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'],
'art': [() => import('./art'), 'pull [--store apple|play] [--write] [--force]: the live store art (App Store screenshot sets, previews, icon; Play icon, feature graphic, screenshots per language) into store/renders + pulled.json, so checks and uploads treat it like rendered art (upsert; differing local files kept without --force; read-only on the stores) | live [--json]: the live art with thumbnails | replace --from <file> --to <path>: one image of the app folder'],
'onboard': [() => import('./onboard'), 'an app that is already live: the live products + subscriptions with their USD / base prices, contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page, from the stores, the code and the app\'s site -> config and .storebound/credentials.json (--write; read-only on the stores, never changes store prices); then the owner questions nothing proves'],
'catalog': [() => import('./catalog'), 'diff: cross-store parity of products + subscriptions (missing / extra / mismatched, prices per region, offers); read-only, exit 1 on mismatches | pull [--write]: store texts of IAPs + subscriptions as a config proposal'],
'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; read-only'],

View file

@ -269,6 +269,16 @@
"name",
"store"
],
"art": [
"apple",
"force",
"from",
"play",
"store",
"to",
"url",
"write"
],
"aso": [
"competitors",
"delay",
@ -451,6 +461,7 @@
"onboard": [
"all",
"allow-price-changes",
"apple",
"base",
"capacitor",
"ceiling",
@ -462,19 +473,25 @@
"file",
"floor",
"flutter",
"force",
"from",
"godot",
"no-art",
"no-catalog",
"no-subscriptions",
"no-web",
"offline",
"out",
"play",
"react-native",
"reconvert",
"source",
"store",
"threshold",
"to",
"unity",
"update-bigmac",
"url",
"with-automatic",
"write"
],
@ -899,6 +916,7 @@
"month",
"name",
"new-version",
"no-art",
"no-captions",
"no-catalog",
"no-declarations",
@ -1160,6 +1178,12 @@
"storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)",
"storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <id>]"
],
"art": [
"storebound art pull [--store apple|play] [--write] [--force] [--dry] [--json]",
"storebound art live [--store apple|play] [--json] the live art per store, language and set with thumbnail",
"storebound art thumb --url <https://...mzstatic.com|googleusercontent.com/...> --json one thumbnail as a data:",
"storebound art replace --from <png|jpg file> --to <path in the renders or frames folder> [--dry] [--json]"
],
"aso": [
"storebound aso rank [--storefronts us,de=de-DE] [--keywords \"a,b\"] [--only-keywords] [--competitors 3] [--limit 200]",
"storebound aso competitors [--term <kw>]... [--storefront us,tr] [--only-terms] [--top 10] [--delay 3500] [--no-save] [--dry] [--json]",
@ -1236,7 +1260,7 @@
"storebound listing live [--json] | set --store play|apple --lang <code> --fields '<json>' [--dry] the texts straight on"
],
"onboard": [
"storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog]"
"storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art]"
],
"originality": [
"storebound originality [--against <folder>[,<folder>]] [--threshold 0.7] [--json]"

256
cli/lib/art-pull.ts Normal file
View file

@ -0,0 +1,256 @@
// Store art pulled from the live stores (`storebound art pull`): where each live image goes in the project's store art
// layout, the upsert plan against the local files, and the manifest (<renders>/pulled.json) that lets the checks and
// the uploads treat pulled files like rendered art. Pure except readPulled / pulledChecksum (they read files).
//
// Layout (the same as `shots render` writes, so check / validate / play images / apple screenshots read it unchanged):
// <renders>/<lang id>/ios69|ios65|iphone-medium|ipad13/NN.png App Store screenshot sets (APP_IPHONE_67, _65, _61,
// APP_IPAD_PRO_3GEN_129); other display types go to
// apple-<type>/ (kept, not checked or uploaded)
// <renders>/<lang id>/preview/iphone|ipad/NN.mp4 App Store app previews (IPHONE_67, IPAD_PRO_3GEN_129)
// <renders>/<lang id>/play|tab7|tab10|tv|wear/NN.png|jpg Play screenshots per type
// <renders>/<lang id>/feature/1024x500.png|jpg Play feature graphic
// <icons.out>/app-store-1024.png, play.icon (or <icons.out>/play-512.png) the icons
// pulled.json: per file (path relative to the renders folder) the store, type, store locale, the original URL's host
// (never the URL), the local file's sha256, the store's own checksum of the uploaded file (App Store: MD5
// sourceFileChecksum; Play: sha256) and when it was pulled; plus `storeEmpty`: the <lang>/<size> sets the store had
// nothing for (a Play language using the default language's graphics, an App Store locale without that set), which
// the local checks then do not demand.
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
export type ArtStore = 'apple' | 'play';
export type ArtKind = 'screenshot' | 'preview' | 'icon' | 'featureGraphic';
/** One live image (or preview) on a store. */
export interface RemoteArt {
store: ArtStore;
kind: ArtKind;
/** ASC screenshotDisplayType / previewType, or the Play image type. */
type: string;
/** Store locale (App Store locale, Play language code); '' for the App Store icon. */
locale: string;
/** Position in its set, from 0. */
index: number;
/** Download URL of the original (App Store: the template at the original size; Play: =s0). */
url: string;
/** A small version for thumbnails. */
thumb: string;
w?: number;
h?: number;
/** The store's checksum of the uploaded file (ASC sourceFileChecksum = MD5, Play sha256); null when it has none. */
checksum: string | null;
/** Stable id of the store asset (ASC id, Play image id, icon token). */
id: string;
}
/** App Store display type -> renders size folder. */
export const APPLE_SHOT_DIR: Record<string, string> = { APP_IPHONE_67: 'ios69', APP_IPHONE_65: 'ios65', APP_IPHONE_61: 'iphone-medium', APP_IPAD_PRO_3GEN_129: 'ipad13' };
/** App Store preview type -> preview folder. */
export const APPLE_PREVIEW_DIR: Record<string, string> = { IPHONE_67: 'iphone', IPAD_PRO_3GEN_129: 'ipad' };
/** Play image type -> renders size folder (screenshots; the feature graphic and icon are files of their own). */
export const PLAY_SHOT_DIR: Record<string, string> = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10', tvScreenshots: 'tv', wearScreenshots: 'wear' };
export const PLAY_TYPES = ['icon', 'featureGraphic', ...Object.keys(PLAY_SHOT_DIR)] as const;
export const nn = (i: number): string => String(i + 1).padStart(2, '0');
export const posix = (p: string): string => p.split(sep).join('/');
/** The size folder of an App Store type (unknown display types keep their own folder, outside the checks). */
export function appleDir(kind: ArtKind, type: string): string {
if (kind === 'preview') return `preview/${APPLE_PREVIEW_DIR[type] ?? `apple-${type.toLowerCase()}`}`;
return APPLE_SHOT_DIR[type] ?? `apple-${type.toLowerCase()}`;
}
/** App Store template URL (`.../{w}x{h}{c}.{f}`) filled in. */
export function fillTemplate(t: string, w: number, h: number, f: 'png' | 'jpg'): string {
return t.replace('{w}', String(w)).replace('{h}', String(h)).replace('{c}', 'bb').replace('{f}', f);
}
/** Thumbnail size: the long side at most `max`, the aspect kept. */
export function thumbSize(w: number, h: number, max = 300): [number, number] {
const s = Math.min(1, max / Math.max(w, h || 1));
return [Math.max(1, Math.round(w * s)), Math.max(1, Math.round(h * s))];
}
/** Play image URL: the original bytes (=s0) or a thumbnail (=w<n>). Any size suffix already there is replaced. */
export const playUrl = (url: string, size: 'original' | number): string => url.replace(/=[a-z0-9-]+$/i, '') + (size === 'original' ? '=s0' : `=w${size}`);
/** Where a live image goes, as a path relative to the project root WITHOUT its extension for images whose format is
* known only after the download (Play), with `ext` the default. null: no config language for this locale. */
export interface Slot { rel: string; ext: string; lang: string; size: string }
export interface SlotCtx {
renders: string;
appleIcon: string;
playIcon: string;
/** Store locale -> config language id. */
langOf: (store: ArtStore, locale: string) => string | null;
}
export function slotOf(r: RemoteArt, c: SlotCtx): Slot | null {
if (r.kind === 'icon') {
const f = r.store === 'apple' ? c.appleIcon : c.playIcon;
const m = /\.(png|jpe?g)$/i.exec(f);
return { rel: m ? f.slice(0, -m[0].length) : f, ext: m ? m[1]!.toLowerCase() : 'png', lang: '', size: 'icon' };
}
const lang = c.langOf(r.store, r.locale);
if (!lang) return null;
if (r.store === 'apple') {
const size = appleDir(r.kind, r.type);
return { rel: `${c.renders}/${lang}/${size}/${nn(r.index)}`, ext: r.kind === 'preview' ? 'mp4' : 'png', lang, size };
}
if (r.kind === 'featureGraphic') return { rel: `${c.renders}/${lang}/feature/1024x500`, ext: 'png', lang, size: 'feature' };
const size = PLAY_SHOT_DIR[r.type];
if (!size) return null;
return { rel: `${c.renders}/${lang}/${size}/${nn(r.index)}`, ext: 'png', lang, size };
}
// ---------------------------------------------------------------------------------------------------------------------
// The manifest
export interface PulledEntry {
store: ArtStore;
kind: ArtKind;
type: string;
locale: string;
/** Host of the original URL (is1-ssl.mzstatic.com, lh3.googleusercontent.com); never the URL itself. */
host: string;
/** sha256 of the local file as pulled. */
sha256: string;
/** The store's checksum of the uploaded original (App Store MD5, Play sha256), when it gives one. */
storeChecksum: string | null;
/** The store asset id (App Store screenshot id, Play image id, icon token hash). */
storeId: string;
pulledAt: string;
}
export interface Pulled {
version: 1;
note: string;
/** Path relative to the renders folder -> entry. */
files: Record<string, PulledEntry>;
/** <lang id>/<size> sets the store had nothing for when pulled. */
storeEmpty: string[];
pulledAt: string;
}
export const PULLED_FILE = 'pulled.json';
export const PULLED_NOTE = 'Store art pulled from the live stores by `storebound art pull` (sha256 of each file, the store and its checksum). Files still matching their sha256 are the store\'s own: uploads skip them and the checks do not ask for render sources. Edit or re-render a file and it counts as your own again.';
export const emptyPulled = (): Pulled => ({ version: 1, note: PULLED_NOTE, files: {}, storeEmpty: [], pulledAt: '' });
const cache = new Map<string, { mtime: number; data: Pulled | null }>();
/** <renders>/pulled.json, or null when there is none (or it does not parse). */
export function readPulled(rendersDir: string): Pulled | null {
const f = join(rendersDir, PULLED_FILE);
let mtime = -1;
try { mtime = statSync(f).mtimeMs; } catch { return null; }
const hit = cache.get(f);
if (hit && hit.mtime === mtime) return hit.data;
let data: Pulled | null = null;
try {
const j = JSON.parse(readFileSync(f, 'utf8')) as Partial<Pulled>;
if (j && typeof j === 'object' && j.files && typeof j.files === 'object') data = { ...emptyPulled(), ...j, files: j.files, storeEmpty: Array.isArray(j.storeEmpty) ? j.storeEmpty : [] } as Pulled;
} catch { data = null; }
cache.set(f, { mtime, data });
return data;
}
export const sha256Of = (b: Uint8Array): string => createHash('sha256').update(b).digest('hex');
export const md5Of = (b: Uint8Array): string => createHash('md5').update(b).digest('hex');
/** The pulled entry of a file that is still exactly as pulled (sha256 equal), else null. */
export function pulledEntry(p: Pulled | null, rendersDir: string, file: string, bytes?: Uint8Array): PulledEntry | null {
if (!p) return null;
const e = p.files[posix(relative(rendersDir, file))];
if (!e) return null;
try { return sha256Of(bytes ?? readFileSync(file)) === e.sha256 ? e : null; } catch { return null; }
}
/** For uploads: the store's checksum of a file that is still exactly as pulled from that store (App Store MD5 / Play
* sha256 of the original upload), so the upload compares the store with itself and sends nothing. null otherwise.
* The manifest is looked up in the file's parent folders (renders/<lang>/<size>/NN.png: three up). */
export function pulledChecksum(file: string, store: ArtStore): string | null {
let d = dirname(file);
for (let i = 0; i < 5; i++) {
if (existsSync(join(d, PULLED_FILE))) {
const e = pulledEntry(readPulled(d), d, file);
return e && e.store === store && e.storeChecksum ? e.storeChecksum : null;
}
const up = dirname(d);
if (up === d) break;
d = up;
}
return null;
}
// ---------------------------------------------------------------------------------------------------------------------
// The plan
export type ArtAction = 'add' | 'same' | 'update' | 'conflict' | 'remove';
export interface LocalFile { rel: string; sha256: string; md5: string }
export interface ArtStep {
action: ArtAction;
/** Target path relative to the project root (with the extension of the local file, or the default one). */
rel: string;
remote: RemoteArt | null;
why: string;
/** Overwrite / remove only with --force. */
needsForce?: boolean;
/** `same` because the local file is the store's (checksum equal) but not yet in the manifest: record it. */
adopt?: boolean;
}
/** What to do with one live image given the local file in its slot (any extension) and the manifest entry of that
* file. Pure. `force`: a local file that differs and is not an unchanged pull is overwritten. */
export function decide(r: RemoteArt, slotRel: string, local: LocalFile | null, entry: PulledEntry | null, force: boolean, defaultExt: string): ArtStep {
if (!local) return { action: 'add', rel: `${slotRel}.${defaultExt}`, remote: r, why: 'not in the project yet' };
const ours = entry && entry.sha256 === local.sha256;
if (ours) {
if (entry.storeId === r.id && (entry.storeChecksum ?? null) === (r.checksum ?? null)) return { action: 'same', rel: local.rel, remote: r, why: 'pulled before, unchanged on the store' };
return { action: 'update', rel: local.rel, remote: r, why: 'changed on the store since the last pull (the local file is the earlier pull, unedited)' };
}
const sameAsStore = !!r.checksum && (r.store === 'play' ? local.sha256 === r.checksum : local.md5 === r.checksum);
if (sameAsStore) return { action: 'same', rel: local.rel, remote: r, why: 'the local file is the one on the store', adopt: true };
return force
? { action: 'update', rel: local.rel, remote: r, why: 'the local file differs from the store (--force: replaced)', needsForce: true }
: { action: 'conflict', rel: local.rel, remote: r, why: entry ? 'edited after the pull: differs from the store' : 'your own file differs from the store', needsForce: true };
}
/** Local NN files of a set beyond what the store has: removed when they are unchanged pulls (or with --force), else a
* conflict (the set would not match the store). Pure. */
export function extraStep(local: LocalFile, entry: PulledEntry | null, force: boolean): ArtStep {
const ours = !!entry && entry.sha256 === local.sha256;
if (ours) return { action: 'remove', rel: local.rel, remote: null, why: 'no longer on the store (an earlier pull)' };
return force ? { action: 'remove', rel: local.rel, remote: null, why: 'not on the store (--force: removed)', needsForce: true }
: { action: 'conflict', rel: local.rel, remote: null, why: 'your own file, not on the store: the set would differ from the store', needsForce: true };
}
export interface ArtSummary { add: number; same: number; update: number; conflict: number; remove: number }
export const summarize = (steps: ArtStep[]): ArtSummary => {
const s: ArtSummary = { add: 0, same: 0, update: 0, conflict: 0, remove: 0 };
for (const x of steps) s[x.action]++;
return s;
};
/** File extension from the first bytes (PNG / JPEG / MP4 / QuickTime). */
export function sniffExt(b: Uint8Array): 'png' | 'jpg' | 'mp4' | 'mov' | null {
if (b.length > 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'png';
if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpg';
if (b.length > 12 && String.fromCharCode(b[4]!, b[5]!, b[6]!, b[7]!) === 'ftyp') return String.fromCharCode(b[8]!, b[9]!) === 'qt' ? 'mov' : 'mp4';
return null;
}
/** Hosts storebound downloads store art from (and the desktop's thumbnails): Apple's and Google's image CDNs only. */
export const ART_HOST_RE = /^https:\/\/([a-z0-9-]+\.)*(mzstatic\.com|googleusercontent\.com|ggpht\.com)\//i;
export const hostOf = (url: string): string => { try { return new URL(url).host; } catch { return ''; } };
/** Every <lang>/<size> of the required store sets that got no file from the store. Pure. */
export function storeEmptySets(langs: { id: string; apple: boolean; play: boolean }[], filled: Set<string>, sizes: { apple: string[]; play: string[] }): string[] {
const out: string[] = [];
for (const l of langs) {
for (const s of l.apple ? sizes.apple : []) if (!filled.has(`${l.id}/${s}`)) out.push(`${l.id}/${s}`);
for (const s of l.play ? sizes.play : []) if (!filled.has(`${l.id}/${s}`)) out.push(`${l.id}/${s}`);
}
return out.sort();
}

View file

@ -365,6 +365,22 @@ export interface DeclarationsResult {
export interface ListingText { play: { path: string; text: string } | null; apple: { path: string; text: string } | null }
/** project.render: one rendered image as a data: URL (null when missing or too big). */
export interface RenderImage { file: string; dataUrl: string | null; bytes: number }
/** art.live (`art live --json`): the store art that is live, per store, locale and set, with thumbnail URLs (art.thumb
* turns one into a data: URL) and whether the app folder has the same file (same / differs / missing; n/a for a
* store-only app). */
export interface LiveArtTile { index: number; type: string; w?: number; h?: number; thumb: string; state: 'same' | 'differs' | 'missing' | 'n/a'; file?: string }
export interface LiveArtSet { store: 'apple' | 'play'; locale: string; lang: string | null; kind: string; type: string; size: string; label: string; tiles: LiveArtTile[] }
export interface LiveArtResult {
generated: string;
stores: { store: 'apple' | 'play'; ok: boolean; version?: string | null; images: number; error?: string }[];
icons: (LiveArtTile & { store: 'apple' | 'play' })[];
sets: LiveArtSet[];
notes: string[];
/** The app folder has a pulled.json (art pull ran). */
pulled: boolean;
}
/** art.thumb: one live thumbnail. */
export interface ArtThumb { dataUrl: string; bytes: number }
/** project.renders (`shots gallery --json`): every render per variant, language and size, with the store check issues. */
export interface RenderIssue { level: 'error' | 'warn'; code: string; msg: string }
export interface RenderTile { src: string; kind: 'image' | 'video'; poster?: string; name: string; w: number; h: number; bytes: number; issues: RenderIssue[] }

View file

@ -333,8 +333,11 @@ export const TOOLS: ToolDef[] = [
description: 'Store titles and descriptions (Play: benefits) of in-app purchases and subscriptions vs the config listings, as a proposal diff. storebound.config.ts is never rewritten; with dry_run false the proposal is saved to .storebound/pull/catalog.json.',
props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
{ name: 'onboard', title: 'Onboard a live app', command: ['onboard'], write: true, network: true, idempotent: true,
description: 'For an app that is already live: reads the live products and subscriptions (USD / base prices), contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page from the stores, the code and the app\'s site, and lists the owner questions nothing proves. Read-only on the stores, never changes store prices. dry_run shows the plan; with dry_run false storebound.config.ts and .storebound/credentials.json are updated (personal data masked, never printed).',
description: 'For an app that is already live: reads the live products and subscriptions (USD / base prices), contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page from the stores, the code and the app\'s site, plus the live store art (art_pull: screenshots, previews, icon, feature graphic into store/renders), and lists the owner questions nothing proves. Read-only on the stores, never changes store prices. dry_run shows the plan; with dry_run false storebound.config.ts, .storebound/credentials.json and the store art files are updated (personal data masked, never printed; differing local art is kept).',
props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
{ name: 'art_pull', title: 'Pull the live store art', command: ['art', 'pull'], write: true, network: true, idempotent: true,
description: 'For an app that is already live: every App Store screenshot set and app preview per locale and display type, the App Store icon, and per Play language the icon, feature graphic and phone / tablet / TV / Wear screenshots, downloaded at their original size into the store art layout (store/renders/<lang>/<size>/NN.png, feature/1024x500, the icons) with store/renders/pulled.json (sha256, store, URL host, when), so check / validate treat them like rendered art and play_images / apple_screenshots see them as the store\'s own (nothing uploaded). Read-only on the stores. An upsert: a re-run changes nothing; a local file that differs from the store (your own render) is kept and listed (only the owner\'s terminal run with --force replaces it). dry_run shows the plan; with dry_run false the files are written (local files only).',
props: { store: { type: 'string', enum: ['apple', 'play'] } }, args: i => [...opt('store', i.store), ...(i.dry_run === false ? ['--write'] : [])] },
{ name: 'whats_new_languages', title: 'Release notes per language', command: ['whats-new'], write: true, network: false, idempotent: true,
description: 'Draft release notes from git into one file per language (store/whats-new/<language id>.md): the first language gets the draft, the others status=translate files for you to translate (keep the header line, set status=translated). Then whats_new_check and whats_new_apply. Local files only.',
props: { languages: { type: 'string', pattern: '^[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+)*$', description: 'Comma-separated language ids or store codes, source language first' }, since: { type: 'string', description: 'Git tag or ref (default: latest tag)' } },
@ -426,7 +429,7 @@ export const FORBIDDEN_ARGS = ['--write', '--yes', '--manual-release', '--cancel
* `scan --write` (compliance answers into the config) stays unreachable: scan is not in this list.
*/
export const LOCAL_WRITE_TOOLS: Record<string, readonly string[]> = {
apple_listing_pull: ['--write'], play_listing_pull: ['--write'], catalog_pull: ['--write'], onboard: ['--write'],
apple_listing_pull: ['--write'], play_listing_pull: ['--write'], catalog_pull: ['--write'], onboard: ['--write'], art_pull: ['--write'],
};
export function inputSchema(t: ToolDef): Record<string, unknown> {

View file

@ -1,6 +1,6 @@
// Onboarding an app that is already live on a store (after `storebound init` and `listing pull`): reads what the
// stores and the code prove and puts it into the project, so the owner is asked only what nothing proves.
// storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog]
// storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art]
// Read-only on both stores (GETs; a Play edit that is opened, read and deleted, never committed). Without --write it
// prints the plan; --write (never with --dry) changes only local files:
// 1. products / subscriptions in storebound.config.ts from the live catalog of both stores: ids, type, listings per
@ -13,9 +13,13 @@
// account set on App Store Connect goes into .storebound/credentials.json reviewDemoAccount, never printed.
// 4. accounts.deletionUrl, when the app has accounts and none is set: a deletion page found on the app's own site
// (website, privacy policy links). Not found is reported as the gap it is, never invented.
// 5. the live store art (art pull): App Store screenshot sets, previews and icon, Play icon, feature graphic and
// screenshots into store/renders with pulled.json, so check / validate / uploads treat them like rendered art;
// local files that differ are kept (art pull --force replaces them).
// Then the owner questions that are left (one batch).
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { artPull, printPull } from './art';
import { appleItems, appleTexts, playItems, playTexts } from './catalog';
import { ascClient, type J } from './apple/asc';
import type { StoreItem } from './lib/catalog';
@ -171,6 +175,16 @@ export async function run(ctx: Ctx): Promise<void> {
} else if (config.play) questions.push('Account deletion page (Play requires a web page to request account deletion): none was found on the app\'s site; publish one, then set accounts.deletionUrl (account-deletion skill)');
}
report.deletion = deletion;
// 5. the store art (icon, screenshots, previews, feature graphic) from the live stores into store/renders, so
// nothing has to be rendered again for an app that is already live (art pull; upsert, local files kept)
if (!a.has('no-art')) {
try {
const art = await artPull(ctx, { store, write, force: false, quiet: json });
report.art = { summary: art.summary, conflicts: art.conflicts, unmapped: art.unmapped, written: art.written, stores: art.stores };
if (!json) printPull(art, write);
} catch (e) { warn(TAG, `store art: ${(e as Error).message.slice(0, 300)}`); }
}
report.questions = questions;
const changed = text !== before;

View file

@ -25,6 +25,7 @@ import { byPlayCode, need } from '../lib/ctx';
import { draftReason, draftSections } from '../lib/listing-plan';
import { done, log, step } from '../lib/log';
import { playClient } from './client';
import { pulledChecksum } from '../lib/art-pull';
import { readPlayCopy } from './listing';
import { variantRoot } from '../shots/lib';
import { resolvedTablets } from '../shots/report';
@ -114,6 +115,8 @@ export function describePlan(plan: ImageChange[]): string {
export const noListing = (e: unknown): boolean => /-> 404\b/.test(String((e as Error)?.message ?? e)) && /listing/i.test(String((e as Error)?.message ?? e));
const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex');
/** sha256 for the compare with Play: a file still exactly as `art pull` saved it counts with Play's own sha256. */
const storeSha = (f: string): string => pulledChecksum(f, 'play') ?? sha256(f);
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
@ -162,7 +165,7 @@ export async function run(ctx: Ctx): Promise<void> {
for (const code of codes) {
const li = sets.get(code)!, out: ImageChange[] = [];
try {
for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(sha256)));
for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(storeSha)));
for (const type of li.missing) out.push(missingChange(type, (await remoteOf(id, code, type)).length, prune));
} catch (e) {
if (!noListing(e)) throw e;
@ -199,7 +202,7 @@ export async function run(ctx: Ctx): Promise<void> {
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;
if (imageChange(type, remote, files.map(storeSha)).change === 'same') return;
types++;
if (remote.length) await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`);
for (const f of files) {

View file

@ -41,6 +41,11 @@ const opt = (flag: string, v: unknown): string[] => (v === undefined || v === nu
const lang = { type: 'string', pattern: '^[A-Za-z0-9_-]{1,20}(,[A-Za-z0-9_-]{1,20}){0,99}$', description: 'Comma-separated language ids from the config (default: all)' };
/** A price sheet: any .csv file path the owner picked (read only), never an option. */
export const PRICE_SHEET_RE = /^(?!-)[^\x00-\x1f]{1,1000}\.[cC][sS][vV]$/;
/** A live art thumbnail: https on Apple's or Google's image CDN only. */
export const ART_THUMB_RE = /^https:\/\/([a-z0-9-]+\.)*(mzstatic\.com|googleusercontent\.com|ggpht\.com)\/[^\s"'<>]{1,1500}$/;
/** The picked image of art.replace (absolute, PNG / JPEG) and the app folder file it replaces (relative, no ..). */
export const ART_FROM_RE = /^\/[^\x00-\x1f]{1,1000}\.([pP][nN][gG]|[jJ][pP][eE]?[gG])$/;
export const ART_TO_RE = /^(?![/-])(?!.*\.\.)[A-Za-z0-9 _./-]{1,500}\.(png|jpe?g|PNG|JPE?G)$/;
/** MCP tools the desktop profile leaves out (sending builds to testers on Play, release state changes: the window has
* the owner actions for those; workspace and release-train reads: the window has portfolio.status for those; the raw API reader api_get). */
@ -51,12 +56,12 @@ export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|com
/** Read tools whose command prints a structured result with --json. */
export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'health_issue', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status',
'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status', 'catalog_diff',
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current', 'project_editable', 'listing_live']);
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current', 'project_editable', 'listing_live', 'art_live', 'art_thumb']);
/** Console forms `storebound console fill` knows (cli/console/fill.ts). */
export const CONSOLE_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'];
/** Write tools that print a structured result with --json in the dry run and the real run (console.fill: the answers). */
export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit', 'project_edit', 'listing_set']);
export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner_apple_resubmit', 'project_edit', 'listing_set', 'art_replace']);
/** Write tools whose command may ask its own question (confirm()): a verified host token answers it, as for owner actions. */
const TOKEN_ANSWERS = new Set(['console_fill']);
@ -116,6 +121,17 @@ export const DESKTOP_ONLY: ToolDef[] = [
props: { form: { type: 'string', enum: CONSOLE_FORMS, description: 'The console form' }, store: { type: 'string', enum: ['apple', 'play'], description: 'Default: from the form' },
fresh_profile: { type: 'boolean', default: false, description: "Skip attaching to a Chrome that runs with remote debugging. storebound's own browser profile (the owner signs in there once) is always used otherwise; the owner's Chrome cookies are never copied here" } },
required: ['form'], args: i => [String(i.form), ...opt('store', i.store), ...(i.fresh_profile === true ? ['--fresh-profile'] : [])] },
{ name: 'art_live', title: 'Live store art', command: ['art', 'live'], write: false, network: true,
description: 'Read-only: the store art that is live per store, language and set (App Store screenshot sets and previews of the version being prepared, else the live one, and the icon; Play icon, feature graphic and screenshots per language) with thumbnail URLs and whether the app folder has the same file (pulled.json).' },
{ name: 'art_thumb', title: 'One live thumbnail', command: ['art', 'thumb'], write: false, network: true,
description: 'One thumbnail of the live store art as a data: URL (at most 2 MB), from the App Store or Google Play image CDN only.',
props: { url: { type: 'string', pattern: ART_THUMB_RE.source, description: 'A thumb URL from art.live' } },
required: ['url'], args: i => ['--url', String(i.url)] },
{ name: 'art_replace', title: 'Replace one store image', command: ['art', 'replace'], write: true, network: false, idempotent: true,
description: 'Replaces one image of the app folder (a screenshot in the renders folder, or a frame a scene shows) with a PNG / JPEG file the owner picked. The dry run shows old -> new size and the store checks (size, alpha, bytes); an error stops it. Nothing goes to a store: upload with the Play / App Store apply after its dry run.',
props: { from: { type: 'string', pattern: ART_FROM_RE.source, description: 'The picked file (absolute path)' },
to: { type: 'string', pattern: ART_TO_RE.source, description: 'The image to replace, relative to the app folder' } },
required: ['from', 'to'], args: i => ['--from', String(i.from), '--to', String(i.to)] },
{ name: 'prices_current', title: 'Current prices', command: ['prices', 'export'], write: false, network: true,
description: 'Read-only: the current price of every product on both stores per region (Apple manual prices, with with_automatic also the equalized ones). Writes nothing.',
props: { store: { type: 'string', enum: ['apple', 'play'] }, with_automatic: { type: 'boolean', default: false } },
@ -246,7 +262,7 @@ const SYSTEM: MethodDescriptor[] = [
];
/** Read methods a store-only app (params.virtual, no app folder) may use: store reads that need only the store ids. */
export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status', 'listing_live']);
export const VIRTUAL_READS = new Set(['apple_status', 'play_status', 'health', 'health_issue', 'reviews_list', 'apple_testflight_status', 'apple_ppo_status', 'listing_live', 'art_live', 'art_thumb']);
/** Writes a store-only app may run (no app folder: they send only what the call carries, never the generated config's
* placeholder links). The confirm token binds them to the store ids (`virtual`) instead of a folder. */
export const VIRTUAL_WRITES = new Set(['listing_set']);

View file

@ -5,6 +5,7 @@ import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { boxOutside, type DeviceBox, PLAY_TABLET_SIZES, SIZES } from './lib';
import { fontIssues, type FontUsage } from './glyphs';
import { pulledEntry, readPulled } from '../lib/art-pull';
import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec';
/** <renders>/report.json: path relative to the renders dir -> flags found while rendering. missing = 404s (frames);
@ -51,6 +52,10 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string
required?: Record<string, boolean> | ((lang: string) => Record<string, boolean>); sizes?: string[];
probe?: (file: string) => ProbeInfo | null }): RendersScan {
const report = readReport(dir);
// art pulled from the stores (art pull): an unchanged pulled file is the store's own (the store took it), and a set
// the store had nothing for (Play's default-language fallback, a locale without that set) is not demanded here:
// validate reports the store side
const pulled = readPulled(dir);
const out: RendersScan = { images: [], videos: [], sets: [], variants: [], langs: o.langs };
const probe = o.probe ?? ffprobe;
for (const variant of [undefined, ...(o.variants ?? [])]) {
@ -66,9 +71,11 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string
const numbers = new Set(files.map(f => f.split('.')[0]));
for (const f of files) {
const file = join(sdir, f);
const info = imageInfo(readFileSync(file));
const bytes = readFileSync(file);
const info = imageInfo(bytes);
const rel = (variant ? `${variant}/` : '') + `${lang}/${size}/${f}`;
const issues = checkImage(info, size);
const fromStore = !variant && !!pulledEntry(pulled, dir, file, bytes);
const issues = fromStore ? [] : checkImage(info, size);
const entry = report[rel.replace(/\.jpe?g$/i, '.png')];
for (const msg of entry?.overflow ?? []) issues.push({ level: 'warn', code: 'overflow', msg });
issues.push(...fontIssues(entry?.fonts));
@ -76,7 +83,7 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string
if (entry && !entry.fonts) issues.push({ level: 'warn', code: 'fonts', msg: 'font usage not recorded (rendered by an older storebound): run shots fonts + shots render again so missing glyphs are caught' });
out.images.push({ rel, lang, variant, size, file, info, issues });
}
const required = req[size] ?? false;
const required = (req[size] ?? false) && !(!variant && !files.length && pulled?.storeEmpty.includes(`${lang}/${size}`));
if (!files.length && !required) continue;
out.sets.push({ lang, variant, size, count: numbers.size, issues: checkCount(numbers.size, size, required) });
}