// 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///NN.png, feature/1024x500.*, the // icons at app-store-1024.png / play.icon, with /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 --json one thumbnail as a data: // URL (at most 2 MB; the desktop window loads no remote images itself) // storebound art replace --from --to [--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 { ignoreInRepo } from './lib/git-ignore'; 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(items: T[], n: number, fn: (x: T) => Promise): Promise { 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 { 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 { 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 { 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 { 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(), play = new Map(); 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; 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(); // one live image per slot: the preferred store locale of the language const best = new Map & 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 /, the best-ranked locale's items const setLocale = new Map(); 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(); const planned = new Set(); const counts = new Map(); 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; steps: { action: string; file: string; why: string; store?: string; type?: string; locale?: string }[]; conflicts: string[]; unmapped: string[]; storeEmpty: string[]; notes: string[]; written: boolean; /** The line added to the app repo's ignore file (pulled art is never committed), or null. */ gitignored?: string | null; 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 { 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 { 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(); 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; } // pulled store art never goes into the app's repo if (existsSync(p.renders)) report.gitignored = ignoreInRepo(p.renders, 'storebound: store art (rendered or pulled from the stores), never committed'); 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(); 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)`); if (r.gitignored) log(TAG, `.gitignore: added ${r.gitignored} (store art stays out of the repo)`); 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 = { APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'iPad 13" display', APP_DESKTOP: 'Mac App Store', 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 { 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(); 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 { 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'); }