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

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');
}