diff --git a/.cursor/rules/storebound.mdc b/.cursor/rules/storebound.mdc index 865e027..12a65c7 100644 --- a/.cursor/rules/storebound.mdc +++ b/.cursor/rules/storebound.mdc @@ -89,7 +89,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2. - `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game - `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text - `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal -- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default +- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default - `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly - `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages - `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 08bdd98..e158d35 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -91,7 +91,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2. | `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game | | `aso-basics` | App store optimization basics for a mobile app's or game's text | | `build-and-sign` | Sign, build and upload a mobile app or game from the terminal | -| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default | +| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default | | `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly | | `listing-localization` | Translate the store listing of a mobile app or game into more languages | | `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` | diff --git a/AGENTS.md b/AGENTS.md index 2717231..0185fab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2. - `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game - `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text - `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal -- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default +- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default - `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly - `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages - `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` diff --git a/CHANGELOG.md b/CHANGELOG.md index 02f8d2a..02d0d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## Unreleased +- Play console steps without an API, from a real first release (2026-10-12; pitfalls 27 and 28). New agent-only + `console plan` / `console verify` forms (also in the MCP `console_plan` / `console_verify` lists): + `play-store-settings` (the App category: `play listing` cannot set it, so a new app showed "Not selected"; the + first Edit > App or game > the Category `[role=option]` > Save), `play-app-content` (every card of + `/app-content/overview` in order with its page, its "Start declaration" button, its plan and one batch of + owner questions) and the cards `play-privacy-policy`, `play-ads`, `play-sign-in-details` (the demo account is + typed by the owner, never printed), `play-advertising-id`, `play-government-apps`, `play-financial-features`, + `play-health-apps`; `play-production-countries` (edits.countryAvailability is read-only) and + `play-production-release` (Edit release > Next > Save; the owner's "Send changes for review", a real click). + New config: `play.category` (checked against Play's game / app lists; default from an exact App Store category + match), `play.countries`, `play.managedPublishing`, `console.contentRating.details` (the IARC follow-ups each Yes + reveals), `console.appContent`, `social`, `products[].randomRewards` (loot boxes Yes / No from the catalog). + `check` warns without a Play category and counts the App content answers still owed; `validate` adds the category + reminder and "production has 0 countries"; `play status` shows the category as a console step; `play release` + warns that an approved production release goes live by itself while managed publishing is off. +- IARC content rating plan: page 1's IARC Terms of Use box is an agreement (never pressed, never ticked: the click + gate now refuses "terms of use"); the questionnaire is one long page whose Yes answers reveal follow-ups; the + Summary's final button is "Save", not "Submit"; App content forms deep-link to their card pages. +- Every console plan ships `inPageLoop`, a ready-made in-page helper (window.__sb.run, polled through window.__log): + one evaluate call times out at about 45 s on big Play pages. Play plans and `console fill` click the inner + `[role=checkbox]` of material checkboxes like the material radios. +- App Store: appstore-first-release and store-iap-setup send first IAPs with `apple iap-submit --with-version` + (verified on a real first release), not the console; `console verify apple-app-privacy` reads "Published … ago + by …" as the published signal. + - Fix (`play data-safety`): `PSL_SUPPORT_DATA_DELETION_BY_USER` is Play's general "Do you provide a way for users to request that their data is deleted?". storebound answered it from `partialDeletion` (data deletion without deleting the account), so an app with a delete-account page declared "no". Now `accounts.deletionUrl` answers yes, with diff --git a/GEMINI.md b/GEMINI.md index 87ed86c..e3dc3b6 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -90,7 +90,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2. | `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game | | `aso-basics` | App store optimization basics for a mobile app's or game's text | | `build-and-sign` | Sign, build and upload a mobile app or game from the terminal | -| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default | +| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default | | `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly | | `listing-localization` | Translate the store listing of a mobile app or game into more languages | | `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` | diff --git a/README.md b/README.md index 1f084e2..1e0cbf2 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,12 @@ a terminal first and are never offered over MCP. stops before the final button and tells you what to press. `storebound console verify
[--from-text -]` then checks the page against the config (App Privacy per-type summaries, Play Create app input states, summaries, or the App Store Connect API) and exits 1 on a mismatch. Both are read-only MCP tools (`console_plan`, `console_verify`). + Besides the forms of `console fill`, plans cover the Play pages an agent drives alone: `play-store-settings` (the + App category, which the Play API cannot set), `play-app-content` (every App content card in order with one batch + of owner questions) and its cards (`play-privacy-policy`, `play-ads`, `play-sign-in-details`, + `play-advertising-id`, `play-government-apps`, `play-financial-features`, `play-health-apps`), and the first + production release (`play-production-countries`, `play-production-release`). Each plan ships a ready-made in-page + helper (`inPageLoop`) for long flows. - **Scripted fast path:** `storebound console fill ` (`apple-app-record`, `apple-age-rating`, `apple-app-privacy`, `play-app-record`, `play-content-rating`, `play-target-audience`) opens a visible browser, goes to the form by itself (Apps > + > New App, the app's App Privacy, Play All apps > Create app, diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index b92370c..ab5662c 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -206,6 +206,7 @@ export const WRAPPED: { apple: Record; play: Record { for (const i of profileIssues(config,{ demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) { if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg); } + // Play steps without an API: the App category (Store settings) and the App content answers the owner still owes + const pc = playConsoleReminders(config); + for (const w of pc.warnings) note(w); + for (const h of pc.hints) { hints.push(h); log('check', `hint: ${h}`); } const subs = allSubscriptions(config.subscriptions); if (subs.length) log('check', `subscriptions: ${subs.length} in ${config.subscriptions!.length} group(s)`); const files = [config.icons?.composite, config.icons?.foreground, config.icons?.background, config.shots?.logo, diff --git a/cli/console/fill.ts b/cli/console/fill.ts index 087dcd6..721fa10 100644 --- a/cli/console/fill.ts +++ b/cli/console/fill.ts @@ -122,6 +122,7 @@ async function resolve(root: Locator, f: FieldDef): Promise { // App Store Connect labels "User Access" with a plain heading, not an ARIA group: then the options are looked up // in the whole dialog (their own names, e.g. "Full Access", are unique there) if (g.state === 'ok') { scope = g.loc; seen = true; } + else if (f.scoped) return { field: f, ok: false, seen: false }; } const options: Record = {}; let ok = true; @@ -181,7 +182,11 @@ export async function highlight(loc: Locator, text = CHECK): Promise { async function setField(r: Resolved, v: string | boolean): Promise { const f = r.field; if (f.kind === 'text') { await r.loc.fill(String(v)); return true; } - if (f.kind === 'checkbox') return safeClick(r.loc, await accName(r.loc), v ? 'check' : 'uncheck'); + if (f.kind === 'checkbox') { + const name = await accName(r.loc); + if (await inMaterial(r.loc, 'material-checkbox')) return forbiddenTick(name) ? safeClick(r.loc, name, 'check') : materialToggle(r.loc, v === true); + try { return await safeClick(r.loc, name, v ? 'check' : 'uncheck'); } catch (e) { if (!await inMaterial(r.loc, 'material-checkbox')) throw e; return materialToggle(r.loc, v === true); } + } if (f.kind === 'radio') { const o = r.options?.[String(v)]; if (!o) return false; @@ -209,8 +214,24 @@ async function setField(r: Resolved, v: string | boolean): Promise { return true; } -/** The radio sits in Play Console's (or is a [role=radio] itself). */ -const inMaterialRadio = (o: Locator): Promise => o.evaluate((el: any) => !!el.closest('material-radio')).catch(() => false); +/** The control sits in one of Play Console's material elements (, ). */ +const inMaterial = (o: Locator, tag: string): Promise => o.evaluate((el: any, t: string) => !!el.closest(t), tag).catch(() => false); +const inMaterialRadio = (o: Locator): Promise => inMaterial(o, 'material-radio'); + +/** + * Play Console's (the same model problem as material-radio, seen on App content and the IARC + * questionnaire, 2026-10-12): a DOM click on the inner [role=checkbox] when its aria-checked differs from `want`. + * The name gate (agreements) runs before. true when the box ends up as wanted. + */ +export async function materialToggle(o: Locator, want: boolean): Promise { + return o.evaluate((el: any, w: boolean) => { + const host = el.closest('material-checkbox'); + const inner = el.getAttribute('role') === 'checkbox' ? el : host?.querySelector('[role="checkbox"]') ?? el; + const state = () => (inner.hasAttribute('aria-checked') ? inner.getAttribute('aria-checked') === 'true' : el.checked === true); + if (state() !== w) inner.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + return state() === w; + }, want).catch(() => false); +} /** * Play Console's (verified 2026-10-12 on Create app): setting the native input, or a click that does @@ -1038,7 +1059,9 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis progress(nav?.ok ? 'the form' : 'the form (opened by you)'); if (o.debugEveryStep && !b.isClosed()) await dump(active() ?? b.page, 'start'); - const last = def.sections.length - 1; + // the IARC follow-ups (optional sections) appear only after a Yes: the form ends with its last required section + const required = def.sections.flatMap((x, i) => (x.optional ? [] : [i])); + const last = required.at(-1) ?? def.sections.length - 1; const privacy = form === 'apple-app-privacy'; /** App Privacy: the data types storebound answers (their entries in the Data Types section open their questions). */ const types = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))]; @@ -1056,7 +1079,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis /** App Privacy openers clicked (each at most twice: a click that opens nothing is a UI change). */ const opened = new Map(); let askedCollects = false; - const finished = () => !pendingAdvance && !waitFor && (done.size === def.sections.length || (!privacy && done.has(last))); + const finished = () => !pendingAdvance && !waitFor && (required.every(i => done.has(i)) || (!privacy && done.has(last))); /** The dialog of the last filled section when it is still visible, else null. */ const anchorDialog = async (): Promise => { diff --git a/cli/console/plan.ts b/cli/console/plan.ts index 0c5a5ee..1a6bbcc 100644 --- a/cli/console/plan.ts +++ b/cli/console/plan.ts @@ -7,7 +7,8 @@ // ~/.storebound/console-accounts.json) and the workspace's account ids; --lookup also asks the App Store Connect API // for the app id by bundle id (a GET; needs the API key). Pure logic in cli/lib/console-plan.ts. MCP: console_plan. import type { Ctx } from '../lib/ctx'; -import { FORM_IDS, formMeta, isForm } from '../lib/console-answers'; +import { formMeta, isForm, isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers'; +import { reviewDemoAccount } from '../lib/credentials'; import { consolePlan, type PlanIds, planText } from '../lib/console-plan'; import { printJson } from '../lib/log'; @@ -16,18 +17,21 @@ export async function run(ctx: Ctx): Promise { const args = ctx.args, json = args.has('json'); const form = args._[0]; if (!form) { - const forms = FORM_IDS.map(id => { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable }; }); + const forms = PLAN_FORM_IDS.map(id => { + if (isForm(id)) { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable, scripted: m.fillable }; } + return { id, store: 'play' as const, title: consolePlan(id, config).title, fillable: true, scripted: false }; + }); if (json) return printJson({ forms }); console.log('storebound console plan [--json] [--lookup] (then drive the page yourself; `storebound console verify ` checks it)\n'); - for (const f of forms) console.log(` ${f.id.padEnd(22)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? '' : ' (guide only)'}`); + for (const f of forms) console.log(` ${f.id.padEnd(26)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? (f.scripted ? '' : ' (agent only, no console fill)') : ' (guide only)'}`); return; } - if (!isForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${FORM_IDS.join(', ')})`); + if (!isPlanForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${PLAN_FORM_IDS.join(', ')})`); const { withAccountIds, readAccounts } = await import('./accounts'); const cfg = withAccountIds(config); const pkg = cfg.play?.packageName; const cached = pkg ? readAccounts().play[pkg] : undefined; - const ids: PlanIds = { playDeveloperId: cfg.play?.developerId ?? cached?.developerId ?? null, playAppId: cfg.play?.appId ?? cached?.appId ?? null }; + const ids: PlanIds = { playDeveloperId: cfg.play?.developerId ?? cached?.developerId ?? null, playAppId: cfg.play?.appId ?? cached?.appId ?? null, demoAccount: !!reviewDemoAccount(root) }; if (form.startsWith('apple-') && !cfg.apple?.appId && cfg.apple?.bundleId) { const { appleAppId } = await import('./app-id'); // without --lookup only the cache is read: the lookup refuses, so no request is made diff --git a/cli/console/verify.ts b/cli/console/verify.ts index 77bd9dd..ce3e2f5 100644 --- a/cli/console/verify.ts +++ b/cli/console/verify.ts @@ -2,12 +2,14 @@ // storebound console verify [--from-text ] [--json] // apple-app-privacy: the page text of the App Privacy page (the Data Types section's per-type summaries; the App Store // Connect API has no App Privacy endpoint). play-app-record: the JSON of the plan's capture snippet (input states) or -// the page text. play-content-rating, play-target-audience: the summary page text. apple-app-record: the App Store +// the page text. play-content-rating, play-target-audience: the summary page text. play-app-content: the plan's +// capture JSON (cards still showing "Start declaration"); play-store-settings, the other App content cards and +// play-production-countries / -release: the page text after Save. apple-app-record: the App Store // Connect API (the app by bundle id; a GET) or, with --from-text, the page text. apple-age-rating: the API. // Exit 1 on any mismatch, and when nothing could be matched. Pure logic in cli/lib/console-verify.ts. MCP: console_verify. import { readFileSync } from 'node:fs'; import type { Ctx } from '../lib/ctx'; -import { FORM_IDS, isForm } from '../lib/console-answers'; +import { isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers'; import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify'; import { printJson } from '../lib/log'; @@ -20,7 +22,7 @@ export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const args = ctx.args, json = args.has('json'); const form = args._[0]; - if (!isForm(form)) throw new Error(`console verify [--from-text ]: one of ${FORM_IDS.join(', ')}`); + if (!isPlanForm(form)) throw new Error(`console verify [--from-text ]: one of ${PLAN_FORM_IDS.join(', ')}`); const from = args.get('from-text'), inline = args.get('text'); let r: VerifyResult; if (from !== undefined || inline !== undefined) { diff --git a/cli/data/flags.json b/cli/data/flags.json index 188bd45..e7ed165 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -288,7 +288,9 @@ "expo", "flutter", "godot", + "name", "react-native", + "sku", "storefront", "unity" ], diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 8d046a8..6c86806 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -55,6 +55,10 @@ export interface Product { platforms?: Platform[]; /** Apple offer codes for this one-time purchase (`storebound apple offer-codes`). */ offerCodes?: IapOfferCodeDef[]; + /** The purchase gives a random reward (loot box, gacha pull, mystery pack). The Play IARC questionnaire asks it + * (Digital Purchases > random items): any true answers it Yes, every product false answers it No. Brazil's ClassInd + * rates such games 18 (seen on a real run, 2026-10-12). */ + randomRewards?: boolean; } export interface Language { @@ -514,6 +518,9 @@ export interface ShipConfig { accounts?: AccountsConfig; /** Sign-in for store reviewers (demo account; credentials live in .storebound/ only). */ reviewAccess?: ReviewAccess; + /** What players / users can do with each other (chat, sharing, friends). Answers the Play IARC questionnaire's + * Miscellaneous > user interaction questions (`console plan play-content-rating`). */ + social?: SocialConfig; languages: Language[]; play?: { packageName: string; defaultLanguage?: string; listing?: string; icon?: string; @@ -532,6 +539,19 @@ export interface ShipConfig { name?: string; /** Free (true) or paid (false) on Play. A free app can never become paid later; storebound flags it for the owner. */ free?: boolean; + /** Play Console > Store presence > Store settings > App category, by Play's English name ("Strategy", "Puzzle", + * "Tools"; PLAY_GAME_CATEGORIES / PLAY_APP_CATEGORIES). The Play Developer API has no category field (`play + * listing` cannot set it): an agent sets it with `storebound console plan play-store-settings`. Default: mapped + * from apple.info.categories when there is an exact match (GAMES_STRATEGY -> Strategy). */ + category?: string; + /** Countries / regions of the production track (ISO 3166-1 alpha-2, e.g. ["US", "GB", "TR"]). Default: all. The API's + * edits.countryAvailability is read-only, so an agent sets it in Play Console with `storebound console plan + * play-production-countries` before the first production release; `validate` reports a track with 0 countries. */ + countries?: string[]; + /** Play Console > Publishing overview > Managed publishing, which only the console sets (no API). true: approved + * changes wait until the owner publishes them. Off by default on Play: a release goes live automatically as soon as + * Google approves it, and `play release` / the release plan say so. Record here what the console shows. */ + managedPublishing?: boolean; /** Play 7" and 10" tablet screenshots (renders tab7/ and tab10/). Optional on Play (required: icon, feature graphic, * at least 2 phone screenshots), but without them the app is not eligible for tablet featuring. Default: true when * tablet renders exist under the renders folder, else false. false = `shots render` / `shots check`, `validate` and @@ -707,13 +727,132 @@ export const RATING_CATEGORIES = ['game', 'social', 'other'] as const; export const AGE_GROUPS = ['5-under', '6-8', '9-12', '13-15', '16-17', '18-over'] as const; export type AgeGroup = typeof AGE_GROUPS[number]; +/** Users interacting with each other (Play IARC Miscellaneous; see ShipConfig.social). */ +export interface SocialConfig { + /** Users can chat, message, post or share content with each other (in-game chat, comments, shared levels). */ + interaction: boolean; + /** Users can block other users. */ + block?: boolean; + /** Users can report other users or their content. */ + report?: boolean; + /** Chat or shared content is moderated (people or filters). */ + moderation?: boolean; + /** Interaction is limited to friends the user accepted (no strangers). */ + friendsOnly?: boolean; + /** The app shares the user's location with other users. */ + sharesLocation?: boolean; +} + +/** A follow-up the plan does not model, keyed by the question text as the console shows it: true / false (Yes / No + * or tick / untick), a radio option label, or the labels of the checkboxes to tick. */ +export type IarcOther = Record; + +/** + * Follow-up questions of the Play IARC questionnaire (one long page: each Yes reveals its follow-ups; recorded on a + * real run 2026-10-12). Asked only when the section's answer is Yes (Miscellaneous always). Option labels are the + * console's own words (the agent matches them by "starts with"). Anything left out is asked of the owner in the one + * batch at the start (`storebound console plan play-content-rating`). + */ +export interface IarcDetails { + violence?: { + /** Violence against humans / human-like characters (checkbox). */ + humans?: boolean; + /** Violence against non-humans: fantasy creatures, animals, robots (checkbox). */ + nonHumans?: boolean; + /** The setting option as the console labels it (a fantasy or a realistic setting ...). */ + setting?: string; + /** Cartoon / childlike art style. */ + childlike?: boolean; + /** The reaction checkboxes to tick (how characters react to harm), console labels ([] = none). */ + reactions?: string[]; + /** The presentation checkboxes to tick (how violence is shown), console labels ([] = none). */ + presentation?: string[]; + /** The blood level option, console label. */ + blood?: string; + /** Set in a historical war. */ + historicalWar?: boolean; + /** Violence against innocent / defenseless characters. */ + innocents?: boolean; + /** Fierce or realistic sounds of violence. */ + fierceSounds?: boolean; + other?: IarcOther; + }; + fear?: { other?: IarcOther }; + sexuality?: { other?: IarcOther }; + gambling?: { other?: IarcOther }; + language?: { other?: IarcOther }; + controlledSubstances?: { other?: IarcOther }; + crudeHumor?: { other?: IarcOther }; + /** Digital Purchases, after "purchases of digital goods" Yes: random items (default from products[].randomRewards), + * then trading between players (default from social). */ + digitalPurchases?: { lootBoxes?: boolean; playerTrading?: boolean; other?: IarcOther }; + /** Miscellaneous > user interaction follow-ups (default from `social`). */ + userInteraction?: { block?: boolean; report?: boolean; moderation?: boolean; friendsOnly?: boolean; other?: IarcOther }; + /** The other Miscellaneous questions (always asked). */ + miscellaneous?: { naziSymbols?: boolean; koreaHistory?: boolean; terrorism?: boolean; crimes?: boolean; other?: IarcOther }; +} +export const IARC_DETAIL_SECTIONS = ['violence', 'fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor', 'digitalPurchases', 'userInteraction', 'miscellaneous'] as const; +export type IarcDetailSection = typeof IARC_DETAIL_SECTIONS[number]; +/** Every key a details section may have and its kind (validation and the answer sheet); `other` is allowed everywhere. */ +export const IARC_DETAIL_KEYS: Record> = { + violence: { humans: 'boolean', nonHumans: 'boolean', setting: 'label', childlike: 'boolean', reactions: 'labels', presentation: 'labels', blood: 'label', historicalWar: 'boolean', innocents: 'boolean', fierceSounds: 'boolean' }, + fear: {}, sexuality: {}, gambling: {}, language: {}, controlledSubstances: {}, crudeHumor: {}, + digitalPurchases: { lootBoxes: 'boolean', playerTrading: 'boolean' }, + userInteraction: { block: 'boolean', report: 'boolean', moderation: 'boolean', friendsOnly: 'boolean' }, + miscellaneous: { naziSymbols: 'boolean', koreaHistory: 'boolean', terrorism: 'boolean', crimes: 'boolean' }, +}; + +/** Play Console > Policy and programs > App content declarations without a form of their own in ConsoleConfig + * (`storebound console plan play-app-content` lists every card). */ +export interface AppContentConfig { + /** Government apps: developed by or on behalf of a government. */ + government?: boolean; + /** Financial features: false = "My app doesn't provide any financial features"; else the console labels to tick. */ + financialFeatures?: false | string[]; + /** Health apps: false = no health features; else the console labels to tick. */ + healthFeatures?: false | string[]; + /** Advertising ID: the app (or an SDK in it) uses the Android advertising ID. Default: yes with ads.appAdsTxt or a + * dataSafety Device or other IDs entry. */ + advertisingId?: boolean; +} + export interface ConsoleConfig { /** App Store Connect > Apps > + > New App: the SKU (internal, never shown) and who in the team sees the app. */ appRecord?: { sku?: string; userAccess?: 'full' | 'limited' }; - /** Play IARC questionnaire: the category and the answers you decided (true = Yes). */ - contentRating?: { category?: typeof RATING_CATEGORIES[number]; answers?: Partial> }; + /** Play IARC questionnaire: the category, the answers you decided (true = Yes) and their follow-ups (details). */ + contentRating?: { category?: typeof RATING_CATEGORIES[number]; answers?: Partial>; details?: IarcDetails }; /** Play Target audience and content, and the Ads declaration. */ targetAudience?: { ages?: AgeGroup[]; appealsToChildren?: boolean; containsAds?: boolean }; + /** Play App content declarations: government apps, financial features, health apps, advertising ID. */ + appContent?: AppContentConfig; +} + +/** Play Console's game categories (Store settings > App category, app type Game). */ +export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing', + 'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const; +/** Play Console's app categories (Store settings > App category, app type App). */ +export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating', + 'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle', + 'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping', + 'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const; +/** App Store category ids with an exact Play counterpart. */ +const APPLE_TO_PLAY: Record = { + GAMES_ACTION: 'Action', GAMES_ADVENTURE: 'Adventure', GAMES_BOARD: 'Board', GAMES_CARD: 'Card', GAMES_CASINO: 'Casino', GAMES_CASUAL: 'Casual', + GAMES_EDUCATIONAL: 'Educational', GAMES_MUSIC: 'Music', GAMES_PUZZLE: 'Puzzle', GAMES_RACING: 'Racing', GAMES_ROLE_PLAYING: 'Role Playing', + GAMES_SIMULATION: 'Simulation', GAMES_SPORTS: 'Sports', GAMES_STRATEGY: 'Strategy', GAMES_TRIVIA: 'Trivia', GAMES_WORD: 'Word', + BOOKS: 'Books & Reference', REFERENCE: 'Books & Reference', BUSINESS: 'Business', EDUCATION: 'Education', ENTERTAINMENT: 'Entertainment', + FINANCE: 'Finance', FOOD_AND_DRINK: 'Food & Drink', HEALTH_AND_FITNESS: 'Health & Fitness', LIFESTYLE: 'Lifestyle', MEDICAL: 'Medical', + MUSIC: 'Music & Audio', NAVIGATION: 'Maps & Navigation', NEWS: 'News & Magazines', PRODUCTIVITY: 'Productivity', SHOPPING: 'Shopping', + SOCIAL_NETWORKING: 'Social', SPORTS: 'Sports', TRAVEL: 'Travel & Local', UTILITIES: 'Tools', WEATHER: 'Weather', GRAPHICS_AND_DESIGN: 'Art & Design', +}; + +/** The Play App category and where it comes from: play.category, else an exact match of apple.info.categories (the + * first subcategory first, for games), else null. */ +export function playCategoryOf(c: Pick): { category: string; source: string } | null { + if (c.play?.category) return { category: c.play.category, source: 'play.category' }; + const k = c.apple?.info?.categories; + for (const id of [k?.primarySubOne, k?.primary]) if (id && APPLE_TO_PLAY[id]) return { category: APPLE_TO_PLAY[id]!, source: `apple.info.categories (${id})` }; + return null; } const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; @@ -771,6 +910,43 @@ function validateConsole(c: ConsoleConfig): string[] { for (const [k, v] of Object.entries(cr?.answers ?? {})) if (!(IARC_QUESTIONS as readonly string[]).includes(k) || typeof v !== 'boolean') e.push(`console.contentRating.answers.${k}: one of ${IARC_QUESTIONS.join(', ')}, true | false`); for (const a of ta?.ages ?? []) if (!AGE_GROUPS.includes(a)) e.push(`console.targetAudience.ages: "${a}" is not one of ${AGE_GROUPS.join(', ')}`); for (const k of ['appealsToChildren', 'containsAds'] as const) if (ta?.[k] !== undefined && typeof ta[k] !== 'boolean') e.push(`console.targetAudience.${k}: true | false`); + e.push(...validateIarcDetails(cr?.details)); + const ac = c.appContent; + if (ac !== undefined) { + if (!ac || typeof ac !== 'object') e.push('console.appContent: an object'); + else { + for (const k of ['government', 'advertisingId'] as const) if (ac[k] !== undefined && typeof ac[k] !== 'boolean') e.push(`console.appContent.${k}: true | false`); + for (const k of ['financialFeatures', 'healthFeatures'] as const) { + const v = ac[k]; + if (v !== undefined && v !== false && !(Array.isArray(v) && v.length && v.every(x => typeof x === 'string' && x.trim()))) + e.push(`console.appContent.${k}: false (none) or a list of the console's labels to tick`); + } + } + } + return e; +} + +function validateIarcDetails(d: IarcDetails | undefined): string[] { + if (d === undefined) return []; + if (!d || typeof d !== 'object' || Array.isArray(d)) return ['console.contentRating.details: an object']; + const e: string[] = []; + const text = (x: unknown) => typeof x === 'string' && !!x.trim(); + const isOther = (o: unknown) => !!o && typeof o === 'object' && !Array.isArray(o) + && Object.entries(o).every(([k, v]) => k.trim() && (typeof v === 'boolean' || text(v) || (Array.isArray(v) && v.every(text)))); + for (const [sec, body] of Object.entries(d)) { + const at = `console.contentRating.details.${sec}`; + const keys = (IARC_DETAIL_KEYS as Record>)[sec]; + if (!keys) { e.push(`${at}: unknown section (${IARC_DETAIL_SECTIONS.join(', ')})`); continue; } + if (!body || typeof body !== 'object' || Array.isArray(body)) { e.push(`${at}: an object`); continue; } + for (const [k, v] of Object.entries(body as Record)) { + if (k === 'other') { if (!isOther(v)) e.push(`${at}.other: { '': true | false | '