storebound/cli/art.ts

519 lines
33 KiB
TypeScript

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