A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
210 lines
15 KiB
TypeScript
210 lines
15 KiB
TypeScript
// Store readiness report (read-only on both stores): what still blocks a submission, in the order to fix it.
|
|
// storebound validate [--apple] [--play] [--json] [--src store/renders] [--no-local] [--no-health] [--no-scan]
|
|
// Without --apple / --play: every store the config has. Exit code 1 when there is at least one blocker.
|
|
// Reads only: GETs on App Store Connect, a Play edit that is opened, read and deleted (never committed), and plain GETs
|
|
// of the public privacy policy and app-ads.txt URLs. Also checks the local renders folder (if there is one) with the
|
|
// `shots check` rules: wrong pixel size, alpha, too few / many images, Play 8 MB and feature graphic format are
|
|
// blockers (the upload would fail), text overflow flags from the last render are warnings. --no-local skips it.
|
|
// Subscriptions (when the config has them): App Store states, localizations, group names, review screenshots; Play
|
|
// subscriptions with an active base plan. App profile: delete-account URL answers (accounts), demo account locally and
|
|
// on App Review (sign-in required), Terms of Use URL answers and is linked in the description (subscriptions).
|
|
// Project scan (offline, `storebound scan`): rejection risks proven by the project's own files (sign-in without a demo
|
|
// account, no account deletion, no Restore Purchases, ATT, purpose strings, privacy manifest, target SDK ...): open
|
|
// blockers stay blockers, high and medium risks are warnings, owner-accepted ones (scan.ignore) are left out. --no-scan skips.
|
|
// Live apps: crash / ANR vitals (Play Reporting API) and App Store diagnostics, as in `storebound health`: over a Play
|
|
// bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips.
|
|
import { ascClient, type J } from './apple/asc';
|
|
import { requiredDisplayTypes } from './apple/screenshots';
|
|
import { type Loaded, playCodes, profileOf, sellsOn } from './lib/config';
|
|
import { reviewDemoAccount } from './lib/credentials';
|
|
import { allSubscriptions, basePlanId } from './lib/subscriptions';
|
|
import type { Ctx } from './lib/ctx';
|
|
import { checkUrl } from './lib/http';
|
|
import { collectHealth } from './health';
|
|
import { healthFindings } from './lib/health';
|
|
import { log, printJson, warn } from './lib/log';
|
|
import { type AppleSnapshot, appleRules, type Finding, formatFindings, type PlaySnapshot, playRequiredImages, playRules, type ProfileFacts, rendersFindings, sortFindings } from './lib/validate-rules';
|
|
import { rendersCheck } from './shots/check';
|
|
import { resolvedTablets } from './shots/report';
|
|
import { runScan } from './lib/scan';
|
|
import type { ScanFinding } from './lib/scan/rules';
|
|
import { playClient } from './play/client';
|
|
import { listOneTimeProducts } from './play/products';
|
|
|
|
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
|
|
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
|
|
const { config, root } = loaded;
|
|
return {
|
|
profile: profileOf(config),
|
|
accounts: !!config.accounts?.enabled,
|
|
inAppDeletion: !!config.accounts?.inApp,
|
|
deletionUrl: config.accounts?.enabled && config.accounts.deletionUrl ? await checkUrl(config.accounts.deletionUrl) : null,
|
|
signInRequired: !!config.reviewAccess?.signInRequired,
|
|
demoAccountLocal: !!reviewDemoAccount(root),
|
|
subscriptions: !!allSubscriptions(config.subscriptions).length,
|
|
termsUrl: config.app.termsUrl ? await checkUrl(config.app.termsUrl) : null,
|
|
};
|
|
}
|
|
|
|
export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|
const { config, root } = ctx.loaded;
|
|
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
|
const appId = await c.app();
|
|
log('validate', 'apple: reading version, build, localizations');
|
|
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
|
|
const v: J = (await c.editableVersion()) ?? versions[0] ?? null;
|
|
let build: AppleSnapshot['build'] = null;
|
|
const locales: AppleSnapshot['locales'] = [];
|
|
let reviewDetail: AppleSnapshot['reviewDetail'] = null;
|
|
if (v) {
|
|
const b = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data;
|
|
if (b) {
|
|
const decl = (await c.maybe(`/v1/builds/${b.id}/appEncryptionDeclaration`))?.data;
|
|
build = { number: b.attributes.version, processingState: b.attributes.processingState, usesNonExemptEncryption: b.attributes.usesNonExemptEncryption ?? null,
|
|
declarationState: decl?.attributes?.appEncryptionDeclarationState ?? null };
|
|
}
|
|
const info = await c.editableAppInfo();
|
|
const infoLocs = new Map((await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=50`)).map((l: J) => [l.attributes.locale, l.attributes] as const));
|
|
for (const l of await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`)) {
|
|
const sets = await c.all(`/v1/appStoreVersionLocalizations/${l.id}/appScreenshotSets?limit=50`);
|
|
const screenshots: Record<string, number> = {};
|
|
for (const s of sets) screenshots[s.attributes.screenshotDisplayType] = (await c.all(`/v1/appScreenshotSets/${s.id}/appScreenshots?limit=50`)).length;
|
|
const ia = infoLocs.get(l.attributes.locale) ?? {};
|
|
locales.push({ locale: l.attributes.locale, name: ia.name, subtitle: ia.subtitle, description: l.attributes.description, keywords: l.attributes.keywords, screenshots });
|
|
}
|
|
reviewDetail = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes ?? null;
|
|
}
|
|
log('validate', 'apple: age rating, price, availability, in-app purchases');
|
|
const info = await c.editableAppInfo();
|
|
const ageRating = (await c.maybe(`/v1/appInfos/${info.id}/ageRatingDeclaration`))?.data?.attributes ?? null;
|
|
const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`);
|
|
const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`);
|
|
const iaps: AppleSnapshot['iaps'] = [];
|
|
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
|
const shot = (await c.maybe(`/v2/inAppPurchases/${p.id}/appStoreReviewScreenshot`))?.data;
|
|
iaps.push({ productId: p.attributes.productId, state: p.attributes.state, reviewScreenshot: !!shot });
|
|
}
|
|
const wantSubs = allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple'));
|
|
const subscriptions: NonNullable<AppleSnapshot['subscriptions']> = [];
|
|
if (wantSubs.length) {
|
|
log('validate', 'apple: subscriptions');
|
|
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
|
|
const groupLocales = (await c.all(`/v1/subscriptionGroups/${g.id}/subscriptionGroupLocalizations?limit=200`)).map((l: J) => ({ locale: l.attributes.locale, state: l.attributes.state }));
|
|
for (const sub of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) {
|
|
const locales = (await c.all(`/v1/subscriptions/${sub.id}/subscriptionLocalizations?limit=200`)).map((l: J) => ({ locale: l.attributes.locale, state: l.attributes.state }));
|
|
const shot = !!(await c.maybe(`/v1/subscriptions/${sub.id}/appStoreReviewScreenshot`))?.data;
|
|
subscriptions.push({ productId: sub.attributes.productId, group: g.attributes.referenceName, state: sub.attributes.state, locales, groupLocales, reviewScreenshot: shot });
|
|
}
|
|
}
|
|
}
|
|
const appleLocale = new Map(config.languages.map(l => [l.id, l.apple] as const));
|
|
return {
|
|
subscriptions, expectedSubscriptions: wantSubs.map(({ group, sub }) => ({ id: sub.id, group: group.name,
|
|
locales: Object.keys(sub.listings).map(id => appleLocale.get(id)).filter((x): x is string => !!x) })),
|
|
profile: await collectProfile(ctx.loaded),
|
|
version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState } : null,
|
|
build, locales, reviewDetail, ageRating,
|
|
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
|
requiredDisplayTypes: requiredDisplayTypes(config.apple),
|
|
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
|
|
availability: !!avail?.data,
|
|
iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id),
|
|
privacyUrl: await checkUrl(config.app.privacyPolicyUrl),
|
|
};
|
|
}
|
|
|
|
export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
|
|
const { config, root } = ctx.loaded;
|
|
const play = await playClient(root, config.play!.packageName);
|
|
const tablets = resolvedTablets(config, ctx.loaded.resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
|
|
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {});
|
|
log('validate', `play: read-only edit ${e.id} (deleted afterwards)`);
|
|
try {
|
|
const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${e.id}/details`);
|
|
const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${e.id}/listings`)).listings ?? [];
|
|
const listings: PlaySnapshot['listings'] = [];
|
|
for (const l of raw) {
|
|
const images: Record<string, number> = {};
|
|
for (const type of Object.keys(playRequiredImages(tablets)))
|
|
images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${e.id}/listings/${l.language}/${type}`)).images ?? []).length;
|
|
listings.push({ ...l, images });
|
|
}
|
|
const tracks = ((await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[]; userFraction?: number }[] }[] }>('GET', `${play.API}/edits/${e.id}/tracks`)).tracks ?? [])
|
|
.map(t => ({ track: t.track, releases: (t.releases ?? []).map(r => ({ status: r.status, versionCodes: r.versionCodes ?? [], userFraction: r.userFraction })) }));
|
|
const products = (await listOneTimeProducts(play)).map(p => ({ productId: p.productId, states: (p.purchaseOptions ?? []).map(o => o.state ?? '') }));
|
|
const website = details.contactWebsite ?? null;
|
|
const appAdsLines = config.ads?.appAdsTxt ?? [];
|
|
const adsLikely = !!config.ads || Object.values(config.dataSafety?.data ?? {}).some(u => u.collect.includes('advertising'));
|
|
let appAds = null;
|
|
if (website && (appAdsLines.length || adsLikely)) {
|
|
try { appAds = await checkUrl(new URL('/app-ads.txt', website).href, { body: true }); } catch { appAds = { url: website, status: null, error: 'bad website URL' }; }
|
|
}
|
|
const wantSubs = allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'play'));
|
|
const subscriptions: NonNullable<PlaySnapshot['subscriptions']> = [];
|
|
if (wantSubs.length) {
|
|
let page = '';
|
|
do {
|
|
const r = await play.call<{ subscriptions?: { productId: string; basePlans?: { basePlanId: string; state?: string }[] }[]; nextPageToken?: string }>('GET', `${play.API}/subscriptions?pageSize=100${page ? '&pageToken=' + page : ''}`);
|
|
for (const x of r.subscriptions ?? []) subscriptions.push({ productId: x.productId, basePlans: (x.basePlans ?? []).map(b => ({ basePlanId: b.basePlanId, state: b.state })) });
|
|
page = r.nextPageToken ?? '';
|
|
} while (page);
|
|
}
|
|
return {
|
|
tablets, subscriptions, expectedSubscriptions: wantSubs.map(({ sub }) => ({ id: sub.id, basePlanId: basePlanId(sub) })),
|
|
profile: await collectProfile(ctx.loaded),
|
|
defaultLanguage: details.defaultLanguage ?? config.play?.defaultLanguage ?? 'en-US',
|
|
expectedLanguages: config.languages.flatMap(playCodes), listings, tracks, products,
|
|
expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'play')).map(p => p.id),
|
|
privacyUrl: await checkUrl(config.app.privacyPolicyUrl), website, appAds, appAdsLines, adsLikely,
|
|
};
|
|
} finally {
|
|
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/** Scan findings as validate findings: blocker stays blocker, high / medium become warnings; low and owner-accepted
|
|
* ones are left out (they are in the full `storebound scan` report). */
|
|
export function scanAsFindings(f: ScanFinding[], stores: { apple: boolean; play: boolean }): Finding[] {
|
|
return f.filter(x => !x.accepted && x.severity !== 'low' && stores[x.store]).map(x => ({
|
|
store: x.store, level: x.severity === 'blocker' ? 'blocker' as const : 'warn' as const, code: `scan-${x.rule}`,
|
|
msg: `${x.guideline}: ${x.title}${x.evidence[0] ? ` (${x.evidence[0].file}:${x.evidence[0].line})` : ''}`,
|
|
fix: `${x.fix} · ${x.skill} skill`,
|
|
}));
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const both = !a.has('apple') && !a.has('play');
|
|
const findings: Finding[] = [];
|
|
const dir = ctx.loaded.resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
|
|
const local = a.has('no-local') ? null : rendersCheck(config, dir);
|
|
if (local) log('validate', `local renders: ${dir}`);
|
|
if ((both || a.has('apple')) && config.apple) {
|
|
try { findings.push(...appleRules({ ...(await collectApple(ctx)), renders: local?.apple })); }
|
|
catch (e) { findings.push({ store: 'apple', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('apple', local?.apple)); }
|
|
} else if (a.has('apple')) warn('validate', 'no apple section in the config');
|
|
if ((both || a.has('play')) && config.play) {
|
|
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }
|
|
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); }
|
|
} else if (a.has('play')) warn('validate', 'no play section in the config');
|
|
if (!a.has('no-scan')) {
|
|
log('validate', 'project scan (offline): rejection risks in the project files');
|
|
try {
|
|
const { report } = await runScan(ctx.loaded.root);
|
|
findings.push(...sortFindings(scanAsFindings(report.findings, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play })));
|
|
} catch (e) { warn('validate', `project scan failed: ${(e as Error).message}`); }
|
|
}
|
|
if (!a.has('no-health') && (config.apple || config.play)) {
|
|
log('validate', 'vitals and diagnostics (last 7 days)');
|
|
const h = await collectHealth(ctx, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play, days: 7 });
|
|
findings.push(...sortFindings(healthFindings(h, { unreadable: 'ok' })));
|
|
}
|
|
const blockers = findings.filter(f => f.level === 'blocker').length;
|
|
if (a.has('json')) printJson({ blockers, findings });
|
|
else {
|
|
console.log(formatFindings(findings));
|
|
log('validate', blockers ? `${blockers} blocker(s)` : 'no blockers (UI-only steps are not visible to the API: App Privacy, Play App content)');
|
|
}
|
|
if (blockers) process.exitCode = 1;
|
|
}
|