519 lines
33 KiB
TypeScript
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');
|
|
}
|