# Changelog ## Unreleased - App Privacy purposes (wrong answers in `console fill apple-app-privacy`, `project.declarations` and `scan`): Play "advertising" became both Third-Party Advertising and Developer's Advertising or Marketing (Device ID, Coarse Location, Product Interaction, Performance Data). Now one Apple purpose per Play purpose, following Apple's definitions (developer.apple.com/app-store/app-privacy-details/): advertising -> Third-Party Advertising, analytics -> Analytics, personalization -> Product Personalization, functionality / fraud / account / communications -> App Functionality. Developer's Advertising or Marketing is No unless the owner says so; with a Play "communications" purpose console fill asks. - New `appPrivacy.types`: the owner's App Privacy answers per Apple data type id (all 35: USER_ID, DEVICE_ID, GAMEPLAY_CONTENT, ADVERTISING_DATA, CUSTOMER_SUPPORT, ENVIRONMENT_SCANNING, HANDS, HEAD ...), each `{ collected?, linked?, tracking?, purposes? }` (THIRD_PARTY_ADVERTISING, DEVELOPERS_ADVERTISING, ANALYTICS, PRODUCT_PERSONALIZATION, APP_FUNCTIONALITY, OTHER_PURPOSES), validated against Apple's lists. - `console fill apple-app-privacy` fills them as certain answers (they win over what dataSafety suggests); types with no Play equivalent work from the block alone; anything unset stays "(you decide)". - `scan --write` proposes `collected` + `purposes` for detected types the block does not answer; never `linked` / `tracking` (the SDK disclosures do not prove them). - `check` compares the block with the iOS project, both ways: a type used for tracking needs `NSUserTrackingUsageDescription` and `NSPrivacyTracking` true (and the reverse is flagged), and the app's `NSPrivacyCollectedDataTypes` (manifest keys mapped to App Privacy ids) must match the types marked collected. - templates/app-privacy.md, the store-declarations, console-autofill and store-pitfalls skills document it. - `console fill play-app-record`: Play Console's Create app form, the Play counterpart of `apple-app-record` (the Play Developer API cannot create apps). - Account: `play.developerId`, the workspace account's id or the remembered one; else the only account the login sees; else the chooser entry named `play.developerAccount` (new optional field: the account's display name, e.g. an organisation account). Several accounts with none set, or a name that matches none or two: it stops and lists them. Remembered in `~/.storebound/console-accounts.json`. - Navigation: All apps > the page-level Create app button, through a dedicated allowlist (`createAppAllowed`: exact name "Create app" / "Uygulama oluştur", button or link, on the app list, no Create app form open, not inside a form or dialog). The form's own Create app stays refused. - Fills App name (`play.name`, new, else `app.name`; 30 characters checked), Default language (Play Console label, e.g. "English (United States) – en-US"; matched by code in a Turkish console), App or game (profile) and Free or paid (`play.free`, new, else Free for IAP / ads only) as an owner check: outlined, with the note that a free app can never become paid. The Developer Program Policies and US export laws declarations are never ticked (an `agreement` field is outlined whatever its answer). The log and guidance say the package name is set by the first AAB upload (`play upload --track internal` / `flow run release`). - In `--dry`, the `console fill` list, the MCP / desktop form list and the desktop App info "Play Console forms" card. - `shots check` no longer reports `i drawn with a system font` for captions with ligatures ("Outfits" in en / de / nl with Montserrat). The font usage probes (one span per character, read with CDP `CSS.getPlatformFontsForNode`) sat side by side in one line, so Chrome shaped "f" + "i" into the fi ligature across them and the lone "i" probe reported no glyph of ours. Each probe is now its own block. The Google Fonts `text=` subsets were fine (they keep the GSUB ligatures); regression test with a real Montserrat `text=` subset (`tests/glyph-ligatures.test.ts`). - `--help` / `-h` on every command, subcommand and group (`storebound shots render --help`, `storebound apple --help`) prints the usage (the module's usage lines, the description and the options it accepts) and exits 0 before anything loads; it used to start the command (a full render). An unknown flag (`--dyr`) exits 2 with the usage before anything runs (`cli/data/flags.json`, `bun tools/gen-flags.ts`). `storebound shots` alone lists the shots commands. `storebound ads apple ...` is reachable again (the plain `ads` command used to swallow it). - `shots render --scenes` takes scene ids (the usage said numbers); an unknown id fails at once with the list of ids instead of a 30 s page timeout. - Faster CI: `bun run ci` runs as a task graph (independent steps at once, `[step]`-prefixed output, fail fast, time per step) with the tests split into parallel shards (CPU cores - 1, max 8) balanced by recorded durations, browser suites in shards of their own. New `bun run ci:quick` for the inner loop: typecheck plus the tests related to the change against origin/main (through imports) plus scrub. The desktop Rust host runs only when `src-tauri` or the sidecar build changed (`CI=full` always; Forgejo CI sets it). Fixed sleeps in tests became event waits. - ASO from official and public data only (no scraping, no third-party ASO services): - `aso competitors [--term ] [--storefront us,tr] [--top 10]`: per keyword of the locale's keyword field (and `--term`) the top apps in the iTunes Search API (name, developer, rating count, first release, last update, genre localized to the storefront), the words in their names that the name / subtitle / keywords do not cover (only words of two or more developers: a one-developer word is likely a brand) and the apps new in a term's top N since the last snapshot (`.storebound/aso/competitors/.json`). Same pace as `aso rank` (one call every 3.5 s, stop on 403 / 429). Competitor subtitles are not in any public API and are not shown. - `aso suggest [--locale] [--fresh] [--offline] [--write]`: keyword candidates from the own name / subtitle / description (words and two-word phrases), competitor names, the localized category names (from the Search API's `genres`) and, with Apple Ads credentials, the Apple Ads Platform API keyword suggestions (`POST /v1/suggestions/keywords/query`, relative popularity 0-100). Each candidate shows relevance (own text, competitor names), competition (top apps with the word in their name) and popularity (Apple Ads only, else "unknown"). Proposes a keyword field per locale that fits 100 bytes with the existing rules (no spaces, no duplicates, plural-insensitive, no name / subtitle words, no filler or category words; current terms kept first unless `--fresh`) as old -> new; `--write` puts it into `store/appstore.md` with a draft line that `check --aso` and `aso audit` report until the owner deletes it. - `aso audit`: one offline report: `check --aso` findings, the cross-locale free keyword room, rank trend arrows from the `aso rank` snapshots, competitor coverage gaps, Play short description use and keyword density, App Store conversion from the analytics snapshots, Product Page Optimization / Play listing experiment ideas (suggestions only: PPO cannot test the subtitle; Play experiments stay in Play Console) and the next 3 actions. - MCP: read-only `aso_competitors` (saves no snapshot), `aso_suggest` (never writes), `aso_audit` (offline). - Desktop: an ASO card on the Growth tab (`aso.audit`): keyword field state, keywords ranked, coverage gaps, the rank trend of the best three keywords (visx, rank 1 at the top), the rank table and the next 3 actions. - `examples/demo-app` has listing files (`store/appstore.md`, `store/listing.md`) to try the ASO commands on. - Local font files in `shots.fonts`: `title`, `body` and `fallback[]` take, besides a Google Fonts family, a path (relative to the config) or `{ family, file, weight?, style?, license? }` (TTF / OTF / WOFF / WOFF2, validated by extension and signature; the family comes from the name table when not given). `shots fonts` checks the file's cmap per language, tries the listed fallbacks in order before the automatic Noto ones (`pl title: ę ł ą Ł not in ER Display -> Noto Sans Display`), errors when nothing covers a character, and records the file in fonts.json with its path, sha256 and license. Render and `shots video` serve it in place at `/fonts/local/...` (never copied); the hash in fonts.css invalidates the capture cache, and render refuses a font file changed since `shots fonts`. `shots check` treats the local font and listed fallbacks as configured and warns when a local font has no license note. - Idempotency: every store write is an upsert; running a command twice, or again after a crash or a lost answer, leaves exactly one of each thing (table of every write path: `docs/idempotency.md`). - New `cli/lib/upsert.ts`: `findOrCreate` looks again with a fresh GET right before a create and asks a journal of recent creates (`.storebound/created.json`, a direct GET by id) so a re-run during the store's list lag reuses what the last run made; `createOnce` treats 409 / ALREADY_EXISTS as found and never sends a create blindly again after a timeout or 5xx (it looks first; only keys the store keeps unique get one more try). - The App Store Connect, Google Play, RevenueCat and `storebound api` clients retry 5xx / network errors only for requests that are safe twice (GET, PUT, PATCH, DELETE, relationship adds, state actions, price schedules); a POST create without a clear answer throws `AmbiguousWriteError`. Plan runners treat a 409 duplicate as done and stop on an unclear create instead of retrying. - Uses it in `apple iap`, `apple subscriptions` (groups, subscriptions, localizations, promotional offers, offer code campaigns, custom codes, review screenshot by MD5), `apple offer-codes`, `apple cpp create`, `apple events create`, `apple ppo create`, `apple testflight groups`, `apple bundle`, `apple review-details` (only changed fields), `apple release --phased on`, `apple adhoc register` / `profile`, `apple signing ensure` (a lost certificate answer keeps the key and finds the certificate by its public key), `apple upload` (a COMPLETE build upload is not sent again, an unfinished one is resumed), `analytics setup`, `reviews send`, `revenuecat sync`, `play subscriptions` (subscriptions and offers). - Review submissions (`apple iap-submit`, `cpp submit`, `events submit`): the draft is reused or created once, an item already in it is not added again. - Screenshot and preview sets keep the leading files that already match (MD5, position, delivered), replace the rest in order and fix the order if Apple shows another; a crash midway converges on the next run without duplicates. - Play edits: one open at a time per run, deleted on every error path, committed only on success, deleted instead of committed when nothing changed (`ctl.unchanged()`); redone only when nothing went live, never after a commit without a clear answer. `play images` compares sha256 first and replaces only changed image types; `play release` reuses a versionCode Play already has and skips identical track releases; `play rollout` commits nothing when the track is already like that; `play upload` is done when the versionCode is on the track already. - One writer per app and store: write runs take `.storebound/locks/-.lock` (dry runs and reads do not); a second run stops with who holds it; a lock of a process that is gone is taken over; `--force-unlock`. - `tests/idempotency.test.ts` with stateful store doubles (`tests/helpers/fake-stores.ts`): write commands run twice with zero writes on the second run, a crash in the middle of a screenshot set, IAP and subscription creates whose answer is lost, 409 duplicates, the list lag after a crash, Play edit error paths and an unclear commit, the lock. Pitfall 9 in `docs/pitfalls.md` and the store-pitfalls skill. - The last open rows are closed (`docs/idempotency.md` has none left): - `apple listing`: the App Store version is created through `findOrCreate` (a fresh look right before the create, the journal for the list lag, a lost answer looks first); localizations through `createOnce` (409 = found, then only the differing fields are PATCHed); unchanged localizations and an attached build are left alone. - `play listing`: compares again inside the write edit and PUTs only differing details and languages; nothing left to change = the edit is deleted, not committed. - `apple appinfo`: categories, age rating, content rights compared with the live values, only differences PATCHed. - `play data-safety`: Play cannot read the form back, so the sha256 of the last uploaded CSV is kept in `.storebound/data-safety.sha256`; the same CSV is not sent again unless `--force` (MCP `force`). - Twice-run tests for `apple listing` (plus a lost version create and a 409 localization), `play listing` (plus a store that became current before the edit), `apple appinfo`, `play data-safety`, `apple promoted`, `apple win-back`, `apple preorder`, `apple testflight testers`, `play upload` and `prices import` (Play). - Faster `shots capture` (web) and `shots render` (a 22-language game took about 50 min to capture, serially, with a new context and a full game boot per frame): - `--concurrency N` (and `shots.capture.concurrency`): parallel browser contexts in one Chromium; defaults from the CPU count (capture min(3, cores / 4), render min(4, cores / 2)). Output files do not depend on the order; progress lines carry language / device / frame, n/total and an ETA. Render runs ffmpeg asynchronously and makes the contact sheets after the images. - Per-frame timing (goto, waitFor, eval, delay, screenshot, total) in each progress line and in `.storebound/capture-report.json`, with time per step and the slowest frames at the end. - `shots.capture.session: 'per-language'`: one page per language and device, booted once; shots switch with `navigate: 'spa'` (pushState + popstate, then waitFor / eval) or `'reload'`; a failed precondition falls back to a fresh page (`reuseTimeoutMs`). - Input-hash cache (`.storebound/capture-cache.json`: shot entry, URL, frame size, session, copy text, app build from `shots.capture.build` or the served HTML + scripts, fonts): unchanged frames are skipped; `--only-missing`, `--force`. MCP `shots_capture` (`concurrency`, `mode`) and `shots_render` (`concurrency`). - `apple listing --dry`, `play listing --dry` and `apple appinfo --dry` print a real plan (also `--json`), read-only: the version decision of `apple listing` (keep, update the editable version's string while PREPARE_FOR_SUBMISSION, create, or refuse: number taken, not higher than a reviewed version, another version in review / pending release), the build to attach, and per locale / language create or update with old -> new per field (long text cut with its length, unchanged fields summarized); app info name / subtitle / privacy URL, categories, age rating and content rights the same way. Locales whose section is `status=draft` or `status=translate` (`listing translate`) are skipped with the reason; `--include-drafts` sends them. The real run prints the same plan, asks in a terminal and applies exactly it (unchanged localizations are no longer PATCHed, Play PUTs only changed languages); what's new is left out on an app's first version. - `apple.name`: the App Store name in the primary locale (at most 30 characters, validated) for when the plain name is taken on the App Store ("already being used"). `app.name` stays the Play and on-device name. `console fill apple-app-record` fills Name from it (else `app.name`), and `apple listing`, `apple listing pull` (a pulled name equal to it is not written into the file), the listing completeness view, `check --aso`, the console app picker and `scan`'s beta-wording rule use it for every locale without its own `### name` block. `check` reports an App Store name over 30 characters in any locale and hints when every locale uses the plain `app.name` (`hints` in `--json`). - Pitfalls from real use become guardrails: new skill `store-pitfalls` (read before any store task, linked from the agent rules) and `docs/pitfalls.md` (changelog of lessons; CONTRIBUTING: every real-use bug adds an entry and, where possible, a check or test). - `apple iap-review` names config products missing from App Store Connect's list (it lags a minute after a create) and exits 1 instead of skipping them silently; `apple iap` and `play products` re-read the list after creating and warn when a new product is not in it yet. - `cli/lib/dry.ts` `dryPlan(title, rows)`: the standard dry-run plan table. `apple iap --dry` now reads localizations, price schedule and availability and prints what a real run would add; `apple iap-review` and `play products` end their dry runs with the table. A test scans `cli/**` for `--dry` branches that print only a count (allowlist with TODOs for commands being fixed elsewhere). - Screenshot glyphs (a Turkish render showed fallback glyphs for "ş" / "ğ" while `shots check` passed): the cause was not subsetting (css2 `text=` already takes glyphs from every subset) but the title font: Lilita One has no ş ğ İ and most of Latin Extended-A, so Chrome silently drew them with another font. - `shots fonts` checks every character of every language (copy.json + config languages) against the cmap of the faces that draw it (new pure-TS cmap reader for TTF / OTF / TTC / WOFF / WOFF2, no dependency), adds an automatic Noto fallback per missing character (Noto Sans Display for titles at a heavy weight, Noto Sans, Noto Sans