desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case

This commit is contained in:
CRTkafa 2026-10-14 00:35:29 +08:00
parent 714352ec18
commit 3cea2af4e1
37 changed files with 3020 additions and 54 deletions

View file

@ -30,7 +30,7 @@ const ID = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
export function toTs(v: unknown, ind = ''): string {
const next = ind + ' ';
if (v === null || v === undefined) return 'undefined';
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (Array.isArray(v)) {
const parts = v.map(x => toTs(x, next));

View file

@ -33,6 +33,7 @@
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
import type { ShipConfig } from '../config';
import { allSubscriptions } from '../subscriptions';
import { sdkOfProjectPath } from '../privacy-files';
@ -127,13 +128,6 @@ export const SIGNALS = {
};
const ATT_SDKS_SHOW_PROMPT = new Set(['applovin', 'levelplay']);
const COMPETITORS = ['instagram', 'tiktok', 'whatsapp', 'facebook', 'snapchat', 'youtube', 'netflix', 'spotify', 'telegram', 'messenger', 'twitter', 'pinterest', 'uber', 'airbnb',
'amazon', 'chatgpt', 'openai', 'minecraft', 'roblox', 'fortnite', 'pubg', 'candy crush', 'clash of clans', 'pokemon', 'pokémon', 'among us', 'subway surfers', 'temple run',
'angry birds', 'tetris', 'wordle', 'duolingo', 'zoom', 'gmail', 'google maps', 'waze', 'shazam', 'tinder', 'notion', 'evernote', 'photoshop', 'canva', 'capcut', 'iphone', 'ipad', 'apple'];
const OTHER_PLATFORMS = /\b(Android|Google Play|Play Store|Galaxy Store|AppGallery|Huawei|Windows Phone|BlackBerry|Amazon Appstore)\b/;
// Developer markers count only in capitals ("TODO:", "FIXME"): "todo" is a real word and keyword (a to-do app).
const PLACEHOLDER_MARKERS = /\bTODO\b|\bTBD\b|\bFIXME\b|\bXXX\b/;
const PLACEHOLDER_TEXT = /lorem ipsum|\bplaceholder\b|your app (name|description) here|coming soon|First paragraph:|Short hook under the name|One or two sentences you can change|İsmin altındaki kısa kanca|Yeni sürüm göndermeden istediğin zaman|\bExample (Game|App|Pro|Studio)\b/i;
const PLACEHOLDER = { test: (l: string) => PLACEHOLDER_MARKERS.test(l) || PLACEHOLDER_TEXT.test(l) } as RegExp;
const FGS_PERMISSION: Record<string, string> = {
camera: 'CAMERA', connectedDevice: 'CONNECTED_DEVICE', dataSync: 'DATA_SYNC', health: 'HEALTH', location: 'LOCATION', mediaPlayback: 'MEDIA_PLAYBACK',
@ -368,12 +362,12 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
if (other.length)
add({ rule: 'other-platforms', guideline: '2.3.10', store: 'apple', severity: 'high', title: 'Other mobile platforms or stores named in the App Store metadata or screenshot captions', evidence: other.slice(0, 4),
fix: 'Remove "Android", "Google Play" and other platforms or stores from the App Store text and screenshots (Play listing text is separate: store/listing.md).', skill: 'aso-basics' });
const words = new RegExp(`(^|[\\s,|/–-])(${COMPETITORS.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?=$|[\\s,|/–-])`, 'i');
const words = competitorRe();
const kw = [...listingHits(inp.listings.apple, words, { field: /^(keywords|name|subtitle)$/ })];
if (kw.length)
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'high', title: 'Other apps\' names or trademarks in the name, subtitle or keywords', evidence: kw.slice(0, 4),
fix: 'Use only terms that describe your app; popular app names, trademarks and Apple product names in keywords get the metadata rejected.', skill: 'aso-basics' });
const price = listingHits(inp.listings.apple, /\b(free|best|#1|no\.? ?1|top|cheap|sale|\d+% off|\$\d)\b/i, { field: /^(name|subtitle|keywords)$/ });
const price = listingHits(inp.listings.apple, PRICE_CLAIMS, { field: /^(name|subtitle|keywords)$/ });
if (price.length)
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Prices or claims ("free", "best", "#1") in the name, subtitle or keywords', evidence: price.slice(0, 3),
fix: 'Keep prices and superlatives out of the name, subtitle and keywords (keywords already match "free" searches only by category).', skill: 'aso-basics' });
@ -381,7 +375,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
if (stuffed.length)
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Keyword list in the name or subtitle', evidence: stuffed.slice(0, 2), fix: 'Name and subtitle are a short phrase; keywords belong in the keywords field.', skill: 'aso-basics' });
// 2.2: demos, betas and trial versions belong on TestFlight, not on the App Store (the name and the listing say so)
const BETA = /\b(beta|demo|trial version|test (version|build|app)|testing build|early access|prototype|preview (build|version)|alpha (build|version))\b/i;
const BETA = BETA_WORDS;
const betaName: Hit[] = [];
if (c?.app?.name && BETA.test(c.app.name)) betaName.push(...cfg(c.app.name, `app.name: ${c.app.name}`));
if (c?.apple?.name && c.apple.name !== c.app?.name && BETA.test(c.apple.name)) betaName.push(...cfg(c.apple.name, `apple.name: ${c.apple.name}`));