diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1f47268..fcc4c0d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.47", + "version": "0.8.77", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6b75797..4b980c5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.47", + "version": "0.8.77", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 5e92768..397705c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.47", + "version": "0.8.77", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.cursor/rules/storebound.mdc b/.cursor/rules/storebound.mdc index 18a30b6..2b9d056 100644 --- a/.cursor/rules/storebound.mdc +++ b/.cursor/rules/storebound.mdc @@ -20,6 +20,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds + a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate + per update: `storebound play release --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --yes`, `storebound apple release --manual-release --yes` + (docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the + owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money. **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/.forgejo/actions/linux-setup/action.yml b/.forgejo/actions/linux-setup/action.yml index a16be63..2f0d06c 100644 --- a/.forgejo/actions/linux-setup/action.yml +++ b/.forgejo/actions/linux-setup/action.yml @@ -26,6 +26,7 @@ runs: with: path: | ~/.bun/install/cache + ~/.cache/tauri ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db @@ -35,7 +36,7 @@ runs: shell: bash run: | P="${FORGEJO_PATH:-$GITHUB_PATH}" - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh echo "$HOME/.bun/bin" >> "$P" command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal echo "$HOME/.cargo/bin" >> "$P" diff --git a/.forgejo/workflows/acceptance.yml b/.forgejo/workflows/acceptance.yml index cc5cbb2..e6a4b22 100644 --- a/.forgejo/workflows/acceptance.yml +++ b/.forgejo/workflows/acceptance.yml @@ -33,7 +33,7 @@ jobs: if: ${{ matrix.platform == 'Linux' }} shell: bash run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" - name: Acceptance matrix (Linux) if: ${{ matrix.platform == 'Linux' }} @@ -97,7 +97,7 @@ jobs: path: acceptance-runs/Windows-bun - name: Matrix (markdown), red cells fail the job run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh export PATH="$HOME/.bun/bin:$PATH" V=$(bun tools/desktop-version.ts --print) mkdir -p out diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index d0d95da..656ac95 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -102,7 +102,7 @@ jobs: if: ${{ matrix.platform == 'Linux' }} shell: bash run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" - name: Acceptance matrix (Linux) if: ${{ matrix.platform == 'Linux' }} @@ -173,7 +173,9 @@ jobs: working-directory: apps/desktop run: | rm -rf "$CARGO_TARGET_DIR/release/bundle" - bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci + # the AppImage tools come from GitHub releases, which answer 504 now and then (cached in ~/.cache/tauri) + bash ../../tools/ci-tauri-tools.sh + for i in 1 2 3; do bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci && break; [ $i = 3 ] && exit 1; sleep 30; done - name: Self-test the exact artifacts run: | V=$(bun tools/desktop-version.ts --print) @@ -237,7 +239,8 @@ jobs: bun tools/build-sidecars.ts --target bun-linux-x64 patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu B="$CARGO_TARGET_DIR/release/bundle"; rm -rf "$B" - (cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci) + bash tools/ci-tauri-tools.sh + for i in 1 2 3; do (cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci) && break; [ $i = 3 ] && exit 1; sleep 30; done mkdir -p dev && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" dev/ chmod +x dev/*.AppImage (cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/dev/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test) @@ -371,7 +374,7 @@ jobs: - uses: actions/checkout@v4 - name: Tools run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" - uses: actions/download-artifact@v3 with: @@ -406,7 +409,7 @@ jobs: # no apt: the installers were scanned with the private terms in their own jobs; this job only records - name: Tools run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" [ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; } - uses: actions/download-artifact@v3 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index ee88ef0..e77a9fd 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -61,7 +61,7 @@ jobs: - name: Tag, package.json version, every version copy and the CHANGELOG agree id: check run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + bash tools/ci-install-bun.sh echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" export PATH="$HOME/.bun/bin:$PATH" bun tools/desktop-version.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7ab0224..2082191 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,6 +20,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds + a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate + per update: `storebound play release --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --yes`, `storebound apple release --manual-release --yes` + (docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the + owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money. **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/AGENTS.md b/AGENTS.md index ea2ee8f..e1b9812 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds + a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate + per update: `storebound play release --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --yes`, `storebound apple release --manual-release --yes` + (docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the + owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money. **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a36300..a0bcd5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,156 @@ ## Unreleased +## 0.8.77 · 2026-11-08 + +- **iPhone Duo:** outer and inner files are no longer cut to the first 10: the limit comes from Apple's reference data, and over it nothing changes and nothing is dropped. `apple screenshots --duo-only` writes only the Duo set. `apple cpp` no longer lists creative-assets flags. + +## 0.8.76 · 2026-11-08 + +- 0.8.75 was tagged but not published (GitHub 502/504 in the Linux AppImage bundling). CI now takes the AppImage tools from a checked mirror instead of GitHub. + +## 0.8.75 · 2026-11-08 + +- 0.8.74 was tagged but not published (GitHub release downloads answered 504 during the Linux AppImage bundling); 0.8.75 ships its section. + +## 0.8.74 · 2026-11-08 + +- 0.8.73 was tagged but not published (a GitHub 504 in the Linux AppImage bundling); 0.8.74 ships its section below. CI caches the AppImage tools and retries the Linux bundle. + +## 0.8.73 · 2026-11-08 + +- **iPhone Duo:** the live API names the platform IPHONE_APP_STORE (not IOS); accepted. A Duo problem now warns and the other screenshot sets still upload. + +## 0.8.72 · 2026-11-08 + +- **iPhone Duo screenshots upload.** `apple screenshots` now sends them through the App Asset Library (display class + IPHONE_DUO, placement APP_SCREENSHOT) instead of skipping them as "not in the API" (pitfall 130). + +## 0.8.71 · 2026-11-07 + +- 0.8.69 and 0.8.70 were tagged but not published (the desktop release check stopped them); 0.8.71 ships both sections below. Desktop Portfolio: the Play state of an app uses the same words as Releases ("Not live yet: in review or blocked before review"). + +## 0.8.70 · 2026-11-07 + +- Public MCP server: the bare host is the endpoint too. `POST /` answers MCP like `/mcp` (someone who pastes `https://mcp.example.com` into a connector gets connected), `GET /` sends a browser to the landing page and names the connect URL to any other client. A signed-in server keeps `/` closed. + +## 0.8.69 · 2026-11-07 + +- Play: `play status` and `validate` list every build still active on a testing track below the production build, with the `play release --version-code -- completed` that replaces it (an old build with a policy issue, e.g. READ_MEDIA_IMAGES, blocks Send changes for review for the whole app). A completed production release the store page does not show yet reads "Not live yet: in review or blocked before review" instead of "In review" (CLI and desktop). A commit with `--not-sent-for-review` that Play answers with 400 "Changes are sent for review automatically" is redone without the parameter (pitfalls #129). + +## 0.8.68 · 2026-11-07 + +- Desktop app: `storebound` works in a terminal with only the app installed. On every start the app puts a launcher for its own CLI on the PATH (Windows: `%LOCALAPPDATA%\storebound\bin`, added to the user PATH once; macOS / Linux: `~/.local/bin`, added to `~/.zprofile` / `~/.profile` when missing). An npm / bun install stays first, a file that is not the app's launcher is never overwritten, AppImage runs skip it, `STOREBOUND_NO_CLI_SHIM=1` turns it off. +- `apple resubmit` / `apple submit --platform macos`: `apple.macos.noIap` leaves every in-app purchase out of the macOS submission and, without `apple.macos.gameCenter: true`, the Game Center switch is not touched (no `gameCenterAppVersions` call); the dry run names what it left out and why (pitfalls #128). + +## 0.8.67 · 2026-11-03 + +- `apple build --platform macos`: runs `apple.macos.prebuild` (new) before `tauri build`, no longer `build.prebuild` / `apple.prebuild`, which belong to the iOS project (a Capacitor sync or OTA gate ran for a Tauri Mac build); the dry run names the skipped one. + +## 0.8.66 · 2026-11-03 + +- `apple.macos: { listing, reviewNotes, gameCenter, noIap }`: an app with iOS and macOS versions gets its own Mac listing file and review notes (`apple listing --platform macos`, `apple review-details --platform macos`, new there); `validate` warns about iOS-only claims in a macOS version's texts (Game Center, tap / swipe, iPhone / iPad, in-app purchases with `noIap`). +- `apple upload `: reading the package is bounded (pkgutil signature check 90 s, expand 5 min, then the `apple build` .json) and logged; the processing wait finds macOS builds (it looked only at iOS and sat silently until --timeout); every slow read prints a heartbeat; an SSH / non-GUI session is warned about up front. +- `apple build --platform macos`: `--tauri` takes a `tauri..conf.json` overlay (merged over tauri.conf.json and passed to `tauri build --config`; `tauri.appstore.conf.json` / `tauri.mas.conf.json` next to it are used by themselves); the Mac App Store entitlements come from `--entitlements`, `apple.macos.entitlements` or the merged config (a two-channel project signed the App Store app with its Developer ID plist); the plan lists the final keys, `get-task-allow` / `cs.disable-library-validation` stop the build (`--allow-entitlements`), other hardened-runtime exceptions warn. +- MCP `apple_review_details` takes `platform`. +- `apple release`: a 409 on a release type change is recognized again (a stray control character had broken the pattern). + +## 0.8.65 · 2026-11-03 + +- `validate`: in-app purchases removed from sale (DEVELOPER_REMOVED_FROM_SALE, REMOVED_FROM_SALE) are no blockers; a warning only when the config still sells one. +- `shots capture --device landscape`: Mac App Store / Chromebook frames from a web build (desktop viewport at `shots.landscape.size`, default 1440x900 @2x) into `/landscape/`; on by default when the project renders a landscape size. The simulator and emulator sources skip it with a warning. +- `shots render` / `capture`: "playwright not found" now lists each place looked at and why it failed (an import error was reported as not found). + +## 0.8.64 · 2026-11-02 + +- `validate` / `release plan`: a macOS version is checked against Mac screenshots (APP_DESKTOP, with `apple.mac`), not the iPhone / iPad sets; with no editable version, iOS is picked over macOS. +- `validate --play`: reads go through the locked read-only edit and retry once on a fresh edit when Play ends it ("This Edit has been deleted" was shown as a blocker; it is now a warning to re-run at most). +- Build numbers for macOS (`apple build --platform macos`, `apple upload` of a .pkg) come from every build of the app, as `apple next-build` does (a macOS-only list proposed a number an iOS build already had). +- `apple build --platform macos` runs `build.prebuild` / `apple.prebuild` before `tauri build` (`--no-prebuild` skips it), and the new `apple.macAppCheck` command on the built .app before productbuild ({file} = the .app; a nonzero exit stops the build). +- `apple signing status` / the Mac build dry run on Windows or Linux say the keys live on the Mac that made the certificates (build there) instead of suggesting a new certificate. +- Flows: a dry `flow run` plans the upload, TestFlight wait and distribute steps after a dry build instead of failing on the missing IPA; `beta` and `release` need VERSION (no silent 1.0.0); `beta` distributes to the config's internal TestFlight group when GROUP is empty; `release` has optional macOS steps (MAC_TAURI), PLAY_TRACK (e.g. beta = open testing) and a promote owner step. +## 0.8.63 · 2026-11-02 + +- Desktop: a second start at logon (`--hidden`, e.g. a Startup folder shortcut next to the Run entry) keeps the app in the tray instead of opening its window. + +## 0.8.62 · 2026-10-29 + +- `doctor` warns when storebound runs from a folder macOS guards (~/Desktop, ~/Documents, ~/Downloads, iCloud Drive; e.g. a `bun link` to a checkout there): a launchd job or CI agent without access to it blocks in open() with no output. +- `apple build` prints a line before each slow step (App Store Connect reads, device families, web build), so a run without a terminal that stalls shows where. +## 0.8.61 · 2026-10-29 + +- TestFlight: `apple.testflight.description` takes a language id or its App Store locale (`en` or `en-US`; an `en-US` key was skipped silently), `check` names an unknown key, and `distribute --submit-review` stops (dry run: warns) when the app's primary locale has no beta localization (Beta App Review answers 422 MISSING_BETA_APP_LOCALIZATION). +## 0.8.60 · 2026-10-29 + +- `apple listing` creates a new version with the release type of the newest version before it (MANUAL or AFTER_APPROVAL) when `apple.releaseType` is not set; a first version stays MANUAL. The plan names where the type came from, and warns when `apple.releaseType` differs from the previous version's. +- `play release --dry` uploads nothing: it names the versionCode it would upload and the track changes it would make (the bundle went into a discarded edit, and a long upload could outlive the edit: "This edit has expired"). +- `play build`: an absolute `--dir` / `play.build.dir` stays absolute (it was taken as a folder under the project and lost its leading slash); a `gradlew` without the exec bit (committed from Windows) is made executable with a note to commit the mode. The desktop release signs by key path only. + +## 0.8.59 · 2026-10-29 + +- `mcp --http`: a `tools/call` that accepts `text/event-stream` gets SSE keepalive comments every 20 s and then its result, so long calls survive proxies that cut silent responses. The public server's App Store search keeps one budget of 20 calls a minute (Apple's limit). +- `storebound mcp --http --public`: a public, key-less MCP server (no sign-in, no store credentials, no project folders, no child processes, read-only). Tools: `skills_list`, `skill_get`, `pitfalls_search`, `store_limits`, `listing_lint` and `aso_search` (the public iTunes Search API, the only network call). Rate limit per client (`--rate`, 429 with Retry-After; `--trust-proxy` for X-Forwarded-For), 64 KB request bodies, CORS preflight. docs/mcp.md. + +## 0.8.58 · 2026-10-29 + +- `check` and `scan` are fast on big repos: project walks skip dot folders (tool state, worktree copies), linked git worktrees and the folders git ignores (Pods, Carthage, SourcePackages and ios / android stay in), and read entry types from the listing instead of a stat per file. A game repo with worktree copies went from 244 s to 46 s for `check`. +- `storebound mcp --http`: one Streamable HTTP MCP server that every agent session of a machine shares (no process per session). Each tool gets a `project` argument (absolute path inside `--roots`); auth is a local bearer token (`~/.storebound/mcp-token`, made on first start) or, for a server behind TLS, OAuth bearer tokens checked by introspection (RFC 7662) with protected resource metadata (RFC 9728). docs/mcp.md. + +## 0.8.57 · 2026-10-27 + +- `apple upload` finds the IPA of a build made with `--out`: `apple build` notes its IPA and out folder in `.storebound/last-apple-build.json`, and upload searches `--out` (new flag), then that last build's out, then `apple.build.out` / `build/ios`. When nothing is found, the error names every folder it searched and the last build's IPA. + +## 0.8.56 · 2026-10-27 + +- Release type. `apple.releaseType: afterApproval | manual | scheduled` (+ `apple.earliestReleaseDate`): `apple listing` creates versions with it (MANUAL when unset, existing versions untouched) and `apple submit` / `resubmit` set it before submitting when it differs, the dry run showing old -> new. New `apple release --after-approval | --manual | --scheduled [--platform] [--version] [--first-release] --yes` changes it on a version App Review has not approved yet (clear refusal otherwise, with `--manual-release` as the way out); exit 7 when it already has that type; an update is an agent step, after-approval on the first version of an app is exit 3 OWNER_STEP. Pitfall 125. +- Device families. `validate` and `apple build` read the device families of the last live version (build bundle `deviceProtocols`, device models, iPad screenshot sets) and compare them with TARGETED_DEVICE_FAMILY in the Xcode project and `apple.ipad`; a gap stops `apple build` before the archive with the exact fix (ITMS-90101 otherwise comes after the build; `--allow-device-gap` overrides). Offline `check` warns when the live families are unknown or the config and project disagree. Pitfall 126. +- TestFlight. `apple testflight testers --invite` checks that the tester's group has an installable build (past Beta App Review for external groups); without one it adds the tester, skips the invite and prints when it can go out (exit 0). Apple's 409 NO_INSTALLABLE_BUILDS is handled the same way; `distribute --submit-review` prints the hint for testers not invited yet. Pitfall 127. + +## 0.8.55 · 2026-10-26 + +- `apple build --path-free`: a checkout in a shared build folder outside the home folder is no longer a leak needle (dsymutil and the module cache record it; only home and user-name paths count). + +## 0.8.54 · 2026-10-26 + +- `apple build` is hermetic and can be path-free. `--derived-data ` (`apple.build.derivedData`) is xcodebuild `-derivedDataPath`; with `--out` (or `apple.build.out`) it defaults to `/DerivedData` instead of `~/Library/Developer/Xcode/DerivedData` (without `--out` Xcode's folder stays). `--path-free` (`apple.build.pathFree`) remaps the home folder, checkout, out and DerivedData out of the app binary and the archive's dSYMs / Symbols (Swift `-debug-prefix-map` / `-file-prefix-map`, clang `-ffile-prefix-map`; dwarf-with-dsym stays, symbol upload keeps working), scans the archive and stops before the export with the hit count and the first file + offsets (never the paths). `--scan-paths` only scans and warns. A `--out` outside the home folder prints a hint. `--json` / `latest.json` carry `derivedData`, `pathFree` and `leaks`. Store-pitfalls 124. + +## 0.8.53 · 2026-10-26 + +- Updates are agent steps (the owner's standing rule), first releases stay the owner's. `play release --production completed|inProgress --yes`, `play upload --track production --yes`, `apple submit --build --yes` and `apple release --manual-release --yes` run without an owner gate, in a terminal and in a pipeline, when the store says the app is already live (Play: the production track holds a completed / inProgress / halted release; App Store: a version of the platform is or was `READY_FOR_SALE` / `READY_FOR_DISTRIBUTION` / `REPLACED_WITH_NEW_VERSION`; `cli/lib/release-kind.ts`). The result line carries `releaseKind`; a re-run is exit 7. The first-ever release of an app with `--yes` is exit 3 `OWNER_STEP` ("first release of this app: the owner approves it once"), approved by `--first-release` (after the owner's yes) or bypass mode; a person answering the prompt (no `--yes`) is the approval too. `apple release` is now a ship step (`--json`, `--result-file`, exit 7 when the version is released already). Policy in docs/bypass.md ("Updates are agent steps"), docs/ship-step.md, the release-management and play-first-release skills, AGENTS.md; pitfall 123 / 96. + +## 0.8.52 · 2026-10-26 + +- `analytics snapshot` and `reports` for a collector: a store-only config may name the App Store app by `apple.appId` alone (`bundleId` null or absent; every write still needs it); the Play reports bucket also comes from env `PLAY_REPORTS_BUCKET` or the credential profile's `reportsBucket` when `play.reportsBucket` is missing; `analytics snapshot --json` prints the snapshot as one JSON document on stdout (also when every store failed, with the exit code 1) and `--no-write` skips the file. +- Snapshots carry active-user numbers per day: App Store App Usage (`sessions`, `activeDevices`, `sessionSeconds`, `crashes` from the App Sessions and App Crashes reports; a missing usage report is a `notes` line, not a failure) and Play `activeDeviceInstalls` and `totalUserInstalls` (installs overview file, else the country file summed) next to `installs` (Daily User Installs). +- `reports apple-sales --days N | --from YYYY-MM-DD [--to ...] --json` reads the daily SALES reports of a range into one JSON (per-day summary and totals per currency, range totals per currency, `missing` days listed, never errors); `reports play-sales --days N | --from ...` gives the daily revenue per currency from the estimated sales reports (orders, refunds, charged, refunded, net, by order charged date). MCP `reports` takes `days`, `from`, `to`. +- Release: the dev-channel CI job no longer gates a release. + +## 0.8.51 · 2026-10-26 + +- Tagged but not published (the dev-channel job hit a CDN 502); everything ships in the next version. + +## 0.8.50 · 2026-10-25 + +- App Groups fail early under automatic signing too: `apple signing status` / `ensure` and `apple build` read the App ID's APP_GROUPS capability settings and the newest profile the API lists for each bundle id (an Xcode-managed one too) and compare it with the target's `com.apple.security.application-groups`. A profile without the group (or the capability off) stops `signing ensure` and `apple build` before the archive with the exact portal steps (Certificates, Identifiers & Profiles > Identifiers > the ID > App Groups > Configure > tick the group > Continue > Save), the deep link `.../identifiers/bundleId/edit/`, the plan command and `signing ensure --refresh-profile`; a group nothing can prove only warns with the same steps. `apple build --allow-app-group-gap` overrides. +- New `storebound console plan apple-app-group [--bundle-id ] [--groups group.a,group.b] [--lookup]` (MCP `console_plan`: `bundleId`, `groups`, `lookup`): the agent-first developer portal plan to assign an App Group to an extension's App ID (start URL per bundle id resource id, portal labels, answer sheet per extension, never-press list: Remove / Delete / Revoke). Its final Save is the agent's own (`buttons.finalByAgent`: a capability edit, not a store submission). `console verify apple-app-group` (MCP `console_verify`, API only) re-reads the App ID and the newest profile and fails while a group is missing or cannot be proven. +- Google access through a credential broker (`~/.storebound/broker.json`: `url`, `apiKeyFile`; env `STOREBOUND_BROKER_URL` / `STOREBOUND_BROKER_KEY_FILE`): Play tokens come from the broker's `POST /api/google/token` (in memory until 5 minutes before expiry, one request per account and scope, never logged; ya29. tokens and the API key redacted in errors), the local service account key stays the fallback when the broker does not answer or does not know the account; a broker that refuses the API key stops without retry. `PLAY_SERVICE_ACCOUNT_EMAIL` names the account (env, profile, project file); a profile opts out with `"broker": false`. The key file must be mode 600 on macOS / Linux. Play write commands read the package's Play permissions and enabled APIs from the broker first and stop before any Play call on a missing one (`CAN_MANAGE_TRACK_APKS`, `CAN_MANAGE_PUBLIC_LISTING`, `CAN_MANAGE_ORDERS`, `CAN_MANAGE_APP_CONTENT`, the Games Configuration API) with the exact permission or API and its link; `validate` lists them (gaps are blockers, SDK config drift a warning) and `--json` carries `credentials.play.source`; `doctor`, `accounts` and `apps check` / the desktop's Accounts view say whether tokens come from the broker or the key file. A Google 403 SERVICE_DISABLED / PERMISSION_DENIED in the Play client names the API or grant with its fix link and is never retried (pitfall 122, docs/credential-broker.md). + +## 0.8.49 · 2026-10-25 + +- `play listing` for a language Play does not have yet uploads, in the same edit as the text, everything Play requires to commit it: icon, feature graphic, 2+ phone screenshots from `store/renders//` and the tablet sets when the other languages have them (a set the folder lacks falls back to the default language's). The dry run lists the uploads or refuses with the missing file, instead of a 403 "too few screenshots" at commit. `play images` creates a Play listing that does not exist yet (text from the listing file) together with its images instead of skipping the language (pitfall 102). + +## 0.8.48 · 2026-10-24 + +- Desktop tray: the right-click menu is drawn by the app (the design system's menu look) instead of the OS menu. Each submenu opens as its own small panel; the pin button, Ctrl+click or a middle click keeps a panel open (pinned): it can be dragged, steps aside so it never covers another pinned panel, and comes back after a restart. Values (the apps and their store state, mute) update in place after every store read without closing anything. The native menu stays as a fallback (and on Linux), built from the same menu tree. The left-click tray card is unchanged. +- Desktop right-click menus in the window work the same way: a menu's pin button keeps it open as a floating panel, a submenu can be torn off (its pin button, Ctrl+click or a middle click), pinned panels survive the next menu, can be dragged, and update in place when the data behind them changes ("Gone" when the thing they were for left the screen). - `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas. - `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them). - `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size. + +- `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table. +- App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides. +- `apple build` over SSH / in CI: the refusal now names the fixes (GUI session `.command` file, a CI runner with an unlocked keychain, a build keychain). New `--keychain-unlock` (keychain password from `STOREBOUND_KEYCHAIN_PASSWORD`, never printed; runs `security unlock-keychain` and `set-key-partition-list`) and `--keychain ` (sign with a build keychain). +- `storebound api` writes: the usage, the dry-run message and the refusals show the agent form with an example (`--yes --confirm `), and the store-api skill says both flags are needed. - Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch). - `apple upload` / `play upload` `--pre-check ""` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence ` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`. - New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate ""`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language. diff --git a/GEMINI.md b/GEMINI.md index 3677fc4..144e9e7 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -19,6 +19,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds + a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate + per update: `storebound play release --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --yes`, `storebound apple release --manual-release --yes` + (docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the + owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money. **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 48b121c..395fdfd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.47", + "version": "0.8.77", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 0bf2c5c..be06ab9 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4096,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.47" +version = "0.8.77" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", @@ -4126,6 +4126,7 @@ dependencies = [ "url", "webview2-com", "windows-core", + "winreg", "zip", ] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index f200fc4..95dca43 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.47" +version = "0.8.77" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" @@ -53,6 +53,8 @@ zip = { version = "4", default-features = false, features = ["deflate-flate2-zli [target.'cfg(windows)'.dependencies] webview2-com = "0.39" windows-core = "0.62" +# reads Startup apps' StartupApproved value (already in the tree through the autostart plugin) +winreg = "0.56" [dev-dependencies] tempfile = "3" diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 673bde2..02a67cc 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -5,7 +5,7 @@ include!("src/commands_list.rs"); fn main() { tauri_build::try_build( tauri_build::Attributes::new() - .app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS].concat().into_boxed_slice()))), + .app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS, MENU_COMMANDS].concat().into_boxed_slice()))), ) .expect("tauri build script failed"); } diff --git a/apps/desktop/src-tauri/capabilities/menu.json b/apps/desktop/src-tauri/capabilities/menu.json new file mode 100644 index 0000000..2f5783e --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/menu.json @@ -0,0 +1,21 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "menu-window", + "description": "The tray menu panels (menu-1, menu-2...: small frameless windows the app opens on a right click on the tray icon, one per open or pinned submenu) with the app's own bundled tray-menu page only. They may listen to app events (the live menu tree) and call the menu commands: read their panel, report their size, open, pin and close panels, pick a row, which runs the same tray action as the native menu, and start a drag of their own window (a pinned panel's header). They never read the stores or reach the sidecar; every command re-checks the window label and page origin in Rust.", + "windows": ["menu-*"], + "local": true, + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:window:allow-start-dragging", + "allow-menu-state", + "allow-menu-ready", + "allow-menu-open-sub", + "allow-menu-pin-sub", + "allow-menu-close-subs", + "allow-menu-close", + "allow-menu-dismiss", + "allow-menu-pin", + "allow-menu-select" + ] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png index 701fe9a..bfff39e 100644 Binary files a/apps/desktop/src-tauri/icons/128x128.png and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png index 9b3b288..875659f 100644 Binary files a/apps/desktop/src-tauri/icons/128x128@2x.png and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/256x256.png b/apps/desktop/src-tauri/icons/256x256.png index 9b3b288..875659f 100644 Binary files a/apps/desktop/src-tauri/icons/256x256.png and b/apps/desktop/src-tauri/icons/256x256.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png index 2d4c28a..37cd4c8 100644 Binary files a/apps/desktop/src-tauri/icons/32x32.png and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png index b6d21d6..f3e642c 100644 Binary files a/apps/desktop/src-tauri/icons/64x64.png and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png index a4da06d..4e83a2b 100644 Binary files a/apps/desktop/src-tauri/icons/Square107x107Logo.png and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png index a4cd877..183a972 100644 Binary files a/apps/desktop/src-tauri/icons/Square142x142Logo.png and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png index bf34596..486b26b 100644 Binary files a/apps/desktop/src-tauri/icons/Square150x150Logo.png and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png index ba1b7f7..742469c 100644 Binary files a/apps/desktop/src-tauri/icons/Square284x284Logo.png and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png index 8778d57..23cc203 100644 Binary files a/apps/desktop/src-tauri/icons/Square30x30Logo.png and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png index 331c002..92efd64 100644 Binary files a/apps/desktop/src-tauri/icons/Square310x310Logo.png and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png index 835e26b..2deae72 100644 Binary files a/apps/desktop/src-tauri/icons/Square44x44Logo.png and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png index 19f2b33..def91e6 100644 Binary files a/apps/desktop/src-tauri/icons/Square71x71Logo.png and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png index c1641b8..0f29e78 100644 Binary files a/apps/desktop/src-tauri/icons/Square89x89Logo.png and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png index aa60d96..0a6324a 100644 Binary files a/apps/desktop/src-tauri/icons/StoreLogo.png and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index 24989c3..1236e01 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index 390285c..a0e6f09 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_close.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_close.toml new file mode 100644 index 0000000..f113d1c --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_close.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-menu-close" +description = "Enables the menu_close command without any pre-configured scope." +commands.allow = ["menu_close"] + +[[permission]] +identifier = "deny-menu-close" +description = "Denies the menu_close command without any pre-configured scope." +commands.deny = ["menu_close"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_close_subs.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_close_subs.toml new file mode 100644 index 0000000..dab5968 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_close_subs.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-menu-close-subs" +description = "Enables the menu_close_subs command without any pre-configured scope." +commands.allow = ["menu_close_subs"] + +[[permission]] +identifier = "deny-menu-close-subs" +description = "Denies the menu_close_subs command without any pre-configured scope." +commands.deny = ["menu_close_subs"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_dismiss.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_dismiss.toml new file mode 100644 index 0000000..5c7a0ef --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_dismiss.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-menu-dismiss" +description = "Enables the menu_dismiss command without any pre-configured scope." +commands.allow = ["menu_dismiss"] + +[[permission]] +identifier = "deny-menu-dismiss" +description = "Denies the menu_dismiss command without any pre-configured scope." +commands.deny = ["menu_dismiss"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_open_sub.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_open_sub.toml new file mode 100644 index 0000000..ee4175b --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_open_sub.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-menu-open-sub" +description = "Enables the menu_open_sub command without any pre-configured scope." +commands.allow = ["menu_open_sub"] + +[[permission]] +identifier = "deny-menu-open-sub" +description = "Denies the menu_open_sub command without any pre-configured scope." +commands.deny = ["menu_open_sub"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_pin.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_pin.toml new file mode 100644 index 0000000..6e1dabc --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_pin.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-menu-pin" +description = "Enables the menu_pin command without any pre-configured scope." +commands.allow = ["menu_pin"] + +[[permission]] +identifier = "deny-menu-pin" +description = "Denies the menu_pin command without any pre-configured scope." +commands.deny = ["menu_pin"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_pin_sub.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_pin_sub.toml new file mode 100644 index 0000000..b339f12 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_pin_sub.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-menu-pin-sub" +description = "Enables the menu_pin_sub command without any pre-configured scope." +commands.allow = ["menu_pin_sub"] + +[[permission]] +identifier = "deny-menu-pin-sub" +description = "Denies the menu_pin_sub command without any pre-configured scope." +commands.deny = ["menu_pin_sub"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_ready.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_ready.toml new file mode 100644 index 0000000..c839ac0 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_ready.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-menu-ready" +description = "Enables the menu_ready command without any pre-configured scope." +commands.allow = ["menu_ready"] + +[[permission]] +identifier = "deny-menu-ready" +description = "Denies the menu_ready command without any pre-configured scope." +commands.deny = ["menu_ready"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_select.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_select.toml new file mode 100644 index 0000000..e546f01 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_select.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-menu-select" +description = "Enables the menu_select command without any pre-configured scope." +commands.allow = ["menu_select"] + +[[permission]] +identifier = "deny-menu-select" +description = "Denies the menu_select command without any pre-configured scope." +commands.deny = ["menu_select"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/menu_state.toml b/apps/desktop/src-tauri/permissions/autogenerated/menu_state.toml new file mode 100644 index 0000000..b4844a3 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/menu_state.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-menu-state" +description = "Enables the menu_state command without any pre-configured scope." +commands.allow = ["menu_state"] + +[[permission]] +identifier = "deny-menu-state" +description = "Denies the menu_state command without any pre-configured scope." +commands.deny = ["menu_state"] diff --git a/apps/desktop/src-tauri/src/card.rs b/apps/desktop/src-tauri/src/card.rs index 0e60f4c..e7c85ff 100644 --- a/apps/desktop/src-tauri/src/card.rs +++ b/apps/desktop/src-tauri/src/card.rs @@ -62,7 +62,7 @@ pub fn place(tray: Rect, work: Rect, card_w: f64, card_h: f64, gap: f64) -> (f64 /// Whether a card action word is one the card may send. pub fn valid_action(a: &str) -> bool { - matches!(a, "hide" | "open" | "route" | "refresh" | "mute" | "quit" | "external" | "ready") + matches!(a, "hide" | "open" | "route" | "refresh" | "mute" | "quit" | "external" | "ready" | "size") } // ---- the last snapshot ---- @@ -123,6 +123,38 @@ mod window { Ok(()) } + /// The icon rectangle of the last show, so a size change keeps the card anchored to the icon. + static LAST_ICON: Mutex> = Mutex::new(None); + + /// Card height in CSS px (what the page measured), clamped; the window keeps WIDTH. + fn height_px(h: f64) -> f64 { h.clamp(120.0, HEIGHT) } + + /// Puts the card next to `tray` at its current height: its edge GAP from the icon, inside the work area. + fn anchor(app: &AppHandle, w: &tauri::WebviewWindow, tray: Rect, css_h: f64) { + let monitor = app.monitor_from_point(tray.x + tray.w / 2.0, tray.y + tray.h / 2.0).ok().flatten().or_else(|| w.primary_monitor().ok().flatten()); + if let Some(m) = monitor { + let s = m.scale_factor(); + let wa = m.work_area(); + let work = Rect { x: wa.position.x as f64, y: wa.position.y as f64, w: wa.size.width as f64, h: wa.size.height as f64 }; + let _ = w.set_size(tauri::PhysicalSize::new((WIDTH * s).round() as u32, (css_h * s).round() as u32)); + let (x, y) = place(tray, work, WIDTH * s, css_h * s, GAP * s); + let _ = w.set_position(PhysicalPosition::new(x as i32, y as i32)); + } + } + + /// The page measured its height: resize the window to it and keep it anchored (the bottom edge stays above a bottom + /// taskbar, no empty band under the content). + pub fn resize(app: &AppHandle, css_h: f64) { + let Some(w) = app.get_webview_window(LABEL) else { return }; + let h = height_px(css_h); + if let Ok(mut c) = CURRENT_H.lock() { *c = h; } + if let Some(tray) = LAST_ICON.lock().ok().and_then(|t| *t) { + anchor(app, &w, tray, h); + } + } + + static CURRENT_H: Mutex = Mutex::new(HEIGHT); + /// A left click on the tray icon: shows the card next to the icon, or hides it when it is open. pub fn toggle(app: &AppHandle, tray: Rect) { let Some(w) = app.get_webview_window(LABEL) else { return }; @@ -134,14 +166,9 @@ mod window { if just_hidden() { return; } - let monitor = app.monitor_from_point(tray.x + tray.w / 2.0, tray.y + tray.h / 2.0).ok().flatten().or_else(|| w.primary_monitor().ok().flatten()); - if let Some(m) = monitor { - let s = m.scale_factor(); - let wa = m.work_area(); - let work = Rect { x: wa.position.x as f64, y: wa.position.y as f64, w: wa.size.width as f64, h: wa.size.height as f64 }; - let (x, y) = place(tray, work, WIDTH * s, HEIGHT * s, GAP * s); - let _ = w.set_position(PhysicalPosition::new(x as i32, y as i32)); - } + if let Ok(mut t) = LAST_ICON.lock() { *t = Some(tray); } + let h = CURRENT_H.lock().map(|c| *c).unwrap_or(HEIGHT); + anchor(app, &w, tray, h); let _ = w.show(); let _ = w.set_focus(); let _ = app.emit_to(LABEL, "card-shown", ()); @@ -158,4 +185,4 @@ mod window { } #[cfg(desktop)] -pub use window::{create, hide, toggle}; +pub use window::{create, hide, resize, toggle}; diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 2cbf8f9..5bfa27e 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -49,6 +49,11 @@ fn guard_card(w: &WebviewWindow) -> Res<()> { policy::require_card(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host) } +fn guard_menu(w: &WebviewWindow) -> Res<()> { + let url = w.url().map_err(|e| Fail::host(e.to_string()))?; + policy::require_menu(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host) +} + fn saved(s: &AppState) -> Res> { s.saved.lock().map_err(|_| Fail::host("state poisoned")) } @@ -1350,6 +1355,10 @@ pub fn card_action(window: WebviewWindow, app: AppHandle, action: String, arg: O match action.as_str() { "hide" => crate::card::hide(&app), "ready" => { let _ = app.emit_to(policy::MAIN, "card-request", ()); } + "size" => { + let h: f64 = arg.as_deref().unwrap_or_default().parse().map_err(|_| Fail::host("bad size"))?; + crate::card::resize(&app, h); + } "open" => { crate::card::hide(&app); main(); } "refresh" => { let _ = app.emit_to(policy::MAIN, "tray-action", "read"); } "mute" => { let _ = app.emit_to(policy::MAIN, "tray-action", "mute-toggle"); } @@ -1665,3 +1674,79 @@ pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res Res> { + guard_menu(&window)?; + Ok(crate::menu_panel::panel_state(&window)) +} + +/// A panel measured itself (CSS px): sized, placed inside the work area and shown. +#[tauri::command] +pub fn menu_ready(window: WebviewWindow, width: f64, height: f64) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::ready(&window, width, height); + Ok(()) +} + +/// Opens a submenu beside this panel at the row whose top is `top` (CSS px). +#[tauri::command] +pub fn menu_open_sub(window: WebviewWindow, key: String, top: f64, focus: bool) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::open_sub(&window, &key, top, focus, false); + Ok(()) +} + +/// Ctrl / middle click on a submenu row: opens it as its own pinned panel. +#[tauri::command] +pub fn menu_pin_sub(window: WebviewWindow, key: String, top: f64) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::open_sub(&window, &key, top, false, true); + Ok(()) +} + +/// The pointer moved to a plain row: this panel's submenus close. +#[tauri::command] +pub fn menu_close_subs(window: WebviewWindow) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::close_subs(&window); + Ok(()) +} + +/// Closes this panel (Left / Esc in a submenu, X on a pinned one). +#[tauri::command] +pub fn menu_close(window: WebviewWindow, refocus_parent: bool) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::close(&window, refocus_parent); + Ok(()) +} + +/// Esc on the root menu: every transient panel closes. +#[tauri::command] +pub fn menu_dismiss(window: WebviewWindow) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::dismiss(window.app_handle()); + Ok(()) +} + +/// Pins or unpins this panel. +#[tauri::command] +pub fn menu_pin(window: WebviewWindow, pinned: bool) -> Res<()> { + guard_menu(&window)?; + crate::menu_panel::pin(&window, pinned); + Ok(()) +} + +/// A pick: the same action as the native tray menu (an id tray.rs knows; anything else is refused). +#[tauri::command] +pub fn menu_select(window: WebviewWindow, id: String) -> Res<()> { + guard_menu(&window)?; + if id.len() > 300 || crate::tray::action_of(&id).is_none() { + return Err(Fail::host("unknown menu item")); + } + crate::menu_panel::select(&window, id); + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 1daea05..a076389 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -56,3 +56,6 @@ pub const COMMANDS: &[&str] = &[ /// The tray card window's commands (capabilities/card.json grants them to the `card` window only). pub const CARD_COMMANDS: &[&str] = &["card_get", "card_action"]; + +/// The tray menu panels' commands (capabilities/menu.json grants them to the `menu-*` windows only). +pub const MENU_COMMANDS: &[&str] = &["menu_state", "menu_ready", "menu_open_sub", "menu_pin_sub", "menu_close_subs", "menu_close", "menu_dismiss", "menu_pin", "menu_select"]; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2ef5046..17627f4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ pub mod commands; pub mod confirm; pub mod downloads; pub mod logs; +pub mod menu_panel; pub mod policy; pub mod profiles; pub mod registry; @@ -43,17 +44,76 @@ fn started_hidden() -> bool { std::env::args().any(|a| a == "--hidden") } -/// Tray icon: a click shows the window; the menu (tray.rs) opens the window, reads the stores, lists the apps with their -/// state, mutes notifications, checks for updates, opens Settings and quits (the only real quit on Windows and Linux, -/// where closing the window keeps the app running in the tray). The window refines tooltip, menu and icon after every -/// read (`tray_update`). +/// Tray icon: a left click shows the tray card (card.rs), a right click the app's own menu panels (menu_panel.rs, with +/// the tree from tray.rs: open the window, read the stores, the apps with their state, mute, updates, Settings and Quit, +/// the only real quit on Windows and Linux, where closing the window keeps the app running in the tray). The window +/// refines tooltip, menu and icon after every read (`tray_update`). Linux trays send no clicks: the native menu, +/// rendered from the same tree, stays on the icon there (and anywhere the panels fail). fn tray(app: &tauri::App) -> tauri::Result<()> { - let menu = tray::initial_menu(app.handle())?; - let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false); + let handle = app.handle(); + let panels = handle.state::(); + if let Ok(mut n) = panels.nodes.lock() { + *n = tray::initial_nodes(); + } + let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").show_menu_on_left_click(false); + if cfg!(target_os = "linux") { + panels.native.store(true, std::sync::atomic::Ordering::Relaxed); + b = b.menu(&tray::native_menu(handle, &tray::initial_nodes())?); + } if let Some(icon) = app.default_window_icon() { b = b.icon(icon.clone()); } - b.on_menu_event(|app, e| match tray::action_of(e.id().as_ref()) { + b.on_menu_event(|app, e| dispatch_menu(app, e.id().as_ref())) + .on_tray_icon_event(|t, e| { + let TrayIconEvent::Click { button, button_state: MouseButtonState::Up, rect, position, .. } = e else { return }; + let app = t.app_handle(); + // both open from the icon's rectangle (not the click point): their edge sits next to the icon, above a + // bottom taskbar, so nothing starts inside the taskbar + let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0); + let p = rect.position.to_physical::(scale); + let s = rect.size.to_physical::(scale); + let icon = if s.width > 0.0 && s.height > 0.0 { card::Rect { x: p.x, y: p.y, w: s.width, h: s.height } } else { card::Rect { x: position.x, y: position.y, w: 1.0, h: 1.0 } }; + match button { + MouseButton::Right => menu_panel::open_root(app, menu_panel::Anchor::Tray { x: icon.x, y: icon.y, w: icon.w, h: icon.h }), + MouseButton::Left => { + if app.get_webview_window(card::LABEL).is_none() { + show_main(app); + return; + } + card::toggle(app, icon); + } + _ => {} + } + }) + .build(app)?; + Ok(()) +} + +/// A tray menu pick (a menu panel row or the native fallback menu): tray.rs maps the id to what it does. +/// Whether the person turned storebound off in Windows' Startup apps (Settings / Task Manager), from the +/// HKCU\...\Explorer\StartupApproved\Run value named like the Run entry: its first byte is even when enabled (02, 06), +/// odd when disabled (03, 07). No value: not turned off. Pure. +pub fn startup_approved_off(value: &[u8]) -> bool { + value.first().is_some_and(|b| b & 1 == 1) +} + +#[cfg(windows)] +fn startup_disabled_by_user() -> bool { + use winreg::{enums::HKEY_CURRENT_USER, RegKey}; + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run") + .and_then(|k| k.get_raw_value("storebound")) + .map(|v| startup_approved_off(&v.bytes)) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn startup_disabled_by_user() -> bool { + false +} + +pub fn dispatch_menu(app: &AppHandle, id: &str) { + match tray::action_of(id) { Some(tray::Action::Open) => show_main(app), Some(tray::Action::Quit) => app.exit(0), Some(tray::Action::Event(word)) => { @@ -64,23 +124,7 @@ fn tray(app: &tauri::App) -> tauri::Result<()> { let _ = app.emit_to(policy::MAIN, "tray-route", route); } None => {} - }) - .on_tray_icon_event(|t, e| { - // left click: the tray card next to the icon (right click keeps the native menu) - if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, rect, .. } = e { - let app = t.app_handle(); - if app.get_webview_window(card::LABEL).is_none() { - show_main(app); - return; - } - let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0); - let p = rect.position.to_physical::(scale); - let s = rect.size.to_physical::(scale); - card::toggle(app, card::Rect { x: p.x, y: p.y, w: s.width, h: s.height }); - } - }) - .build(app)?; - Ok(()) + } } /// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is @@ -278,11 +322,12 @@ pub fn run() { early_trace(); linux_webkit_env(); let app = tauri::Builder::default() - // single-instance first: a second start (or a storebound:// link) focuses the running app instead - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app))) + // single-instance first: a second start (or a storebound:// link) focuses the running app instead; a second + // logon start (--hidden: the Run entry plus a Startup folder shortcut) stays in the tray + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| if !argv.iter().any(|a| a == "--hidden") { show_main(app) })) .plugin(tauri_plugin_deep_link::init()) // never restore "visible": a --hidden start would otherwise pop the window up - .plugin(tauri_plugin_window_state::Builder::new().with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build()) + .plugin(tauri_plugin_window_state::Builder::new().with_filter(|label| !label.starts_with(menu_panel::LABEL_PREFIX)).with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build()) .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"]))) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) @@ -332,12 +377,20 @@ pub fn run() { } // start at logon, in the tray: rewritten on every start (Windows: the HKCU Run entry then points at the // running exe, so a moved or updated install fixes itself) - match app.autolaunch().enable() { - Ok(()) => logs::host("info", "autostart at logon set (--hidden, tray)"), - Err(e) => logs::host("warn", &format!("autostart not set: {e}")), + // ...unless the person turned it off (Windows Settings > Apps > Startup / Task Manager): that choice stands + if startup_disabled_by_user() { + logs::host("info", "autostart at logon left off (turned off in Windows Startup apps)"); + } else { + match app.autolaunch().enable() { + Ok(()) => logs::host("info", "autostart at logon set (--hidden, tray)"), + Err(e) => logs::host("warn", &format!("autostart not set: {e}")), + } } app.manage(state); + app.manage(menu_panel::MenuPanels::default()); tray(app)?; + // pinned tray menu panels of the last session come back + menu_panel::restore(app.handle()); if let Err(e) = card::create(app.handle()) { logs::host("warn", &format!("tray card not created: {e}")); } @@ -438,6 +491,15 @@ pub fn run() { commands::ai_fix_undo, commands::card_get, commands::card_action, + commands::menu_state, + commands::menu_ready, + commands::menu_open_sub, + commands::menu_pin_sub, + commands::menu_close_subs, + commands::menu_close, + commands::menu_dismiss, + commands::menu_pin, + commands::menu_select, ]) .build(tauri::generate_context!()) .expect("storebound desktop failed to start"); diff --git a/apps/desktop/src-tauri/src/menu_panel.rs b/apps/desktop/src-tauri/src/menu_panel.rs new file mode 100644 index 0000000..935ac09 --- /dev/null +++ b/apps/desktop/src-tauri/src/menu_panel.rs @@ -0,0 +1,749 @@ +//! The tray's right-click menu, drawn by the app itself instead of the OS menu (tray-menu.html, src/traymenu/). +//! +//! - One tree: tray.rs builds the menu as `Entry`s, turned here into `Node`s. Every submenu has a stable key (its label +//! without the live part), so an open or pinned panel finds its submenu again after the values change. +//! - One window per panel: the root menu and each submenu are their own frameless, always-on-top `menu-N` windows. The +//! page measures itself and calls `menu_ready`; Rust sizes, places (inside the monitor's work area) and shows it. +//! - The panel book (`Book`, pure, tests/menu_panel.rs) records which panels are open, which are pinned and whose child +//! each one is. Transient panels close together (focus left all of them, Esc, a pick); pinned ones stay, can be +//! dragged, step aside so they never cover each other and come back after a restart (`menuPins` in state.json). +//! - Live: every `tray_update` pushes the tree to the open panels (`menu-model`), which update in place. Nothing polls. +//! - Idle: a closed panel is destroyed, never hidden. +//! - Fallback: when a panel window cannot be built or never reports ready, the native tray menu (rendered from the same +//! tree) comes back. Linux always uses it (its tray sends no click events). +//! +//! The top half is pure and covered by tests/menu_panel.rs. +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::tray::Entry; + +pub const LABEL_PREFIX: &str = "menu-"; +pub const PAGE: &str = "tray-menu.html"; +pub const MODEL_EVENT: &str = "menu-model"; +pub const PANEL_EVENT: &str = "menu-panel"; +/// Gap to the work area's edges (physical px at scale 1). +pub const GAP: f64 = 8.0; +/// The estimated size of a panel before it measured itself (logical px). +pub const EST: (f64, f64) = (280.0, 220.0); + +// ---------------------------------------------------------------- model + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Node { + Item { id: String, label: String, enabled: bool }, + Sep, + Sub { key: String, label: String, items: Vec }, +} + +/// A submenu's stable key: its label without status glyphs and without the live part after ':' / '·' / '(' +/// ("Apps (3)" -> "apps", "Apps · 2 need you" -> "apps"). Pinned panels are found again by these keys. +pub fn sub_key(label: &str) -> String { + let head = label.split([':', '\u{00B7}', '(']).next().unwrap_or(""); + let mut out = String::new(); + for c in head.chars() { + if c.is_alphanumeric() { + out.extend(c.to_lowercase()); + } else if (c == ' ' || c == '-' || c == '_') && !out.is_empty() && !out.ends_with('-') { + out.push('-'); + } + } + let out = out.trim_end_matches('-').to_string(); + if out.is_empty() { "sub".into() } else { out } +} + +/// Sibling submenus with the same key get "-2", "-3"... +pub fn unique_keys(mut nodes: Vec) -> Vec { + let mut seen: BTreeMap = BTreeMap::new(); + for n in nodes.iter_mut() { + if let Node::Sub { key, .. } = n { + let count = seen.entry(key.clone()).or_insert(0); + *count += 1; + if *count > 1 { + *key = format!("{key}-{count}"); + } + } + } + nodes +} + +/// The tray menu entries as panel nodes. A submenu keeps the fixed key tray.rs gives it (so a translated label never +/// changes it), else the key comes from its label. +pub fn from_entries(entries: &[Entry]) -> Vec { + let nodes = entries + .iter() + .map(|e| match e { + Entry::Item { id, label, enabled } => Node::Item { id: id.clone(), label: label.clone(), enabled: *enabled }, + Entry::Separator => Node::Sep, + Entry::Sub { key, label, items } => Node::Sub { key: if key.is_empty() { sub_key(label) } else { sub_key(key) }, label: label.clone(), items: from_entries(items) }, + }) + .collect(); + unique_keys(nodes) +} + +/// The items of the submenu at `path` (empty path: the root) and its label. +pub fn find<'a>(nodes: &'a [Node], path: &[String]) -> Option<(&'a [Node], Option<&'a str>)> { + let mut current = nodes; + let mut label = None; + for key in path { + let (items, l) = current.iter().find_map(|n| match n { + Node::Sub { key: k, label: l, items } if k == key => Some((items.as_slice(), l.as_str())), + _ => None, + })?; + current = items; + label = Some(l); + } + Some((current, label)) +} + +// ---------------------------------------------------------------- placement + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Anchor { + /// The tray click point (physical). + Point { x: f64, y: f64 }, + /// The tray icon's rectangle (physical): the root opens from the icon's edge that faces the screen, like the card. + Tray { x: f64, y: f64, w: f64, h: f64 }, + /// Beside a parent panel: its left and right edge and the row's top (physical). + Beside { left: f64, right: f64, top: f64 }, + /// A remembered top-left (pinned panels). + At { x: f64, y: f64 }, +} + +/// x, y, width, height (physical px). +pub type Rect = (f64, f64, f64, f64); + +/// Keeps a panel of size w x h fully inside the work area (with `GAP`). +pub fn clamp_into(x: f64, y: f64, w: f64, h: f64, work: Rect) -> (f64, f64) { + let (wx, wy, ww, wh) = work; + (x.clamp(wx + GAP, (wx + ww - w - GAP).max(wx + GAP)), y.clamp(wy + GAP, (wy + wh - h - GAP).max(wy + GAP))) +} + +/// Where a panel of `size` goes for `anchor`, inside the work area: the root opens away from the taskbar / menu bar the +/// click is near, a submenu beside its parent (flipped left at the screen edge), a pinned panel at its spot. +pub fn place_menu(anchor: Anchor, size: (f64, f64), work: Rect) -> (f64, f64) { + let (w, h) = size; + let (wx, wy, ww, wh) = work; + let (x, y) = match anchor { + Anchor::Point { x, y } => (if x + w > wx + ww { x - w } else { x }, if y + h > wy + wh { y - h } else { y }), + // same rule as the card: the panel's edge sits GAP from the icon, never over the taskbar + Anchor::Tray { x, y, w: iw, h: ih } => return crate::card::place(crate::card::Rect { x, y, w: iw, h: ih }, crate::card::Rect { x: wx, y: wy, w: ww, h: wh }, w, h, GAP), + Anchor::Beside { left, right, top } => (if right + w <= wx + ww - GAP { right - 2.0 } else { left - w + 2.0 }, top - 5.0), + Anchor::At { x, y } => (x, y), + }; + clamp_into(x, y, w, h, work) +} + +pub fn overlaps(a: Rect, b: Rect) -> bool { + a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3 +} + +/// A spot for a newly pinned panel that covers no other pinned panel: below whatever it overlaps, then the next column. +/// Gives up after a while (a screen full of pins) and returns the clamped wish. +pub fn free_spot(want: Rect, others: &[Rect], work: Rect) -> (f64, f64) { + let (mut x, mut y, w, h) = want; + for _ in 0..64 { + let Some(hit) = others.iter().find(|o| overlaps((x, y, w, h), **o)) else { + return clamp_into(x, y, w, h, work); + }; + y = hit.1 + hit.3 + GAP; + if y + h > work.1 + work.3 { + y = work.1 + GAP; + x += w + GAP; + if x + w > work.0 + work.2 { + x = work.0 + GAP; + } + } + } + clamp_into(want.0, want.1, w, h, work) +} + +// ---------------------------------------------------------------- panel book + +#[derive(Clone, Debug, PartialEq)] +pub struct Panel { + pub path: Vec, + pub pinned: bool, + pub parent: Option, + pub anchor: Anchor, + /// Measured and shown once. + pub shown: bool, + /// Physical top-left once shown (kept for pins). + pub pos: Option<(f64, f64)>, + /// Opened from the keyboard: takes focus and starts on its first row. + pub focus: bool, +} + +/// A pinned panel as state.json keeps it (`menuPins`). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct PinRecord { + pub path: Vec, + pub x: i32, + pub y: i32, +} + +/// Which panels are open, which are pinned, who is whose child. +#[derive(Default, Debug)] +pub struct Book { + pub panels: BTreeMap, + next: u32, +} + +impl Book { + fn add(&mut self, path: Vec, pinned: bool, parent: Option, anchor: Anchor) -> String { + self.next += 1; + let label = format!("{LABEL_PREFIX}{}", self.next); + self.panels.insert(label.clone(), Panel { path, pinned, parent, anchor, shown: false, pos: None, focus: false }); + label + } + + fn remove_all(&mut self, labels: &[String]) { + for l in labels { + self.panels.remove(l); + } + } + + pub fn transient(&self) -> Vec { + self.panels.iter().filter(|(_, p)| !p.pinned).map(|(l, _)| l.clone()).collect() + } + + /// Transient panels below `label` (children first). + pub fn descendants(&self, label: &str) -> Vec { + let mut out = Vec::new(); + let mut frontier = vec![label.to_string()]; + while let Some(l) = frontier.pop() { + for (child, p) in &self.panels { + if p.parent.as_deref() == Some(l.as_str()) && !p.pinned && !out.contains(child) { + out.push(child.clone()); + frontier.push(child.clone()); + } + } + } + out + } + + /// A fresh root menu: every transient panel closes, pinned ones stay. Returns the new label and the closed ones. + pub fn open_root(&mut self, anchor: Anchor) -> (String, Vec) { + let closes = self.transient(); + self.remove_all(&closes); + (self.add(vec![], false, None, anchor), closes) + } + + /// Opens submenu `key` of `parent`; its other transient children close. None when that submenu already is the open + /// child, or the parent is gone. `pinned`: torn off as its own pinned panel (one pinned copy per submenu). + pub fn open_sub(&mut self, parent: &str, key: &str, anchor: Anchor, pinned: bool) -> Option<(String, Vec)> { + let mut path = self.panels.get(parent)?.path.clone(); + path.push(key.to_string()); + if !pinned && self.panels.values().any(|p| p.parent.as_deref() == Some(parent) && !p.pinned && p.path == path) { + return None; + } + let mut closes = self.descendants(parent); + self.remove_all(&closes); + if pinned { + let old: Vec = self.panels.iter().filter(|(_, p)| p.pinned && p.path == path).map(|(l, _)| l.clone()).collect(); + for l in old { + closes.extend(self.close(&l)); + } + return Some((self.add(path, true, None, anchor), closes)); + } + Some((self.add(path, false, Some(parent.to_string()), anchor), closes)) + } + + /// Closes `label` and its transient children. + pub fn close(&mut self, label: &str) -> Vec { + if !self.panels.contains_key(label) { + return vec![]; + } + let mut out = self.descendants(label); + out.push(label.to_string()); + self.remove_all(&out); + out + } + + /// Every transient panel closes (Esc on the root, a pick, focus left the menus). + pub fn dismiss(&mut self) -> Vec { + let t = self.transient(); + self.remove_all(&t); + t + } + + /// Pin or unpin. Pinning detaches the panel from its chain: the transient panels that are not its own children + /// close (returned). A second pinned copy of the same submenu closes too. + pub fn pin(&mut self, label: &str, on: bool) -> Vec { + let Some(p) = self.panels.get(label) else { return vec![] }; + let path = p.path.clone(); + if !on { + if let Some(p) = self.panels.get_mut(label) { + p.pinned = false; + } + return vec![]; + } + let mut closes: Vec = self.panels.iter().filter(|(l, p)| p.pinned && p.path == path && l.as_str() != label).map(|(l, _)| l.clone()).collect(); + if let Some(p) = self.panels.get_mut(label) { + p.pinned = true; + p.parent = None; + } + let mine = self.descendants(label); + closes.extend(self.transient().into_iter().filter(|l| l != label && !mine.contains(l))); + self.remove_all(&closes); + closes + } + + /// The tree changed: transient panels whose submenu is gone close (with their children). Pinned ones stay and show + /// that their submenu is gone until it comes back (an app that is read again) or they are closed. + pub fn prune(&mut self, nodes: &[Node]) -> Vec { + let gone: Vec = self.panels.iter().filter(|(_, p)| !p.pinned && find(nodes, &p.path).is_none()).map(|(l, _)| l.clone()).collect(); + let mut out = Vec::new(); + for l in gone { + if self.panels.contains_key(&l) { + out.extend(self.close(&l)); + } + } + out + } + + /// The pinned panels to save (only placed ones; in label order, so the order is stable). + pub fn pins(&self) -> Vec { + self.panels + .values() + .filter(|p| p.pinned) + .filter_map(|p| p.pos.map(|(x, y)| PinRecord { path: p.path.clone(), x: x.round() as i32, y: y.round() as i32 })) + .collect() + } + + /// Recreates pinned panels from a saved list (duplicates and more than `MAX_PINS` dropped). + pub fn restore(&mut self, pins: &[PinRecord]) -> Vec { + let mut out = Vec::new(); + for r in pins.iter().take(MAX_PINS) { + if self.panels.values().any(|p| p.pinned && p.path == r.path) || r.path.len() > 8 { + continue; + } + let (x, y) = (r.x as f64, r.y as f64); + let l = self.add(r.path.clone(), true, None, Anchor::At { x, y }); + if let Some(p) = self.panels.get_mut(&l) { + p.pos = Some((x, y)); + } + out.push(l); + } + out + } +} + +/// At most this many pinned panels are restored after a restart. +pub const MAX_PINS: usize = 12; + +/// A panel label the app made (`menu-` and a number), for the window / command checks. +pub fn is_menu_label(label: &str) -> bool { + label.strip_prefix(LABEL_PREFIX).is_some_and(|n| !n.is_empty() && n.len() <= 9 && n.chars().all(|c| c.is_ascii_digit())) +} + +// ---------------------------------------------------------------- runtime + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PanelInfo { + pub label: String, + pub path: Vec, + pub pinned: bool, + pub has_parent: bool, + pub focus_first: bool, + pub nodes: Vec, +} + +/// The panels' shared state (managed by the app). +#[derive(Default)] +pub struct MenuPanels { + pub book: Mutex, + pub nodes: Mutex>, + /// The native menu took over (a panel failed, or Linux). + pub native: AtomicBool, + /// Bumped on every move of a pinned panel; the save runs once the moves stop. + moves: AtomicU64, +} + +#[cfg(desktop)] +mod runtime { + use super::*; + use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + + fn state(app: &AppHandle) -> tauri::State<'_, MenuPanels> { + app.state::() + } + + fn book(app: &AppHandle, f: impl FnOnce(&mut Book) -> R) -> R { + let s = state(app); + let mut b = s.book.lock().unwrap_or_else(|e| e.into_inner()); + f(&mut b) + } + + fn nodes(app: &AppHandle) -> Vec { + state(app).nodes.lock().map(|n| n.clone()).unwrap_or_default() + } + + pub fn native_fallback(app: &AppHandle) -> bool { + state(app).native.load(Ordering::Relaxed) + } + + /// Puts the native menu (from the current tree) on the tray: the fallback, and Linux. + pub fn set_native(app: &AppHandle) { + let Some(tray) = app.tray_by_id("main") else { return }; + match crate::tray::native_menu(app, &nodes(app)) { + Ok(m) => { + let _ = tray.set_menu(Some(m)); + } + Err(e) => crate::logs::host("warn", &format!("native tray menu not built: {e}")), + } + } + + /// Writes the pinned panels into state.json (`menuPins`). + fn save_pins(app: &AppHandle) { + let pins = book(app, |b| b.pins()); + let Some(s) = app.try_state::() else { return }; + let Ok(mut st) = s.saved.lock() else { return }; + if st.menu_pins == pins { + return; + } + st.menu_pins = pins; + if let Err(e) = crate::store::save(&s.data_dir, &st) { + crate::logs::host("warn", &format!("menu pins not saved: {e}")); + } + } + + /// Stores the latest tree and pushes it to every open panel (they update in place). Panels of a submenu that is gone + /// close when transient. Cheap when nothing changed or no panel is open. + pub fn set_nodes(app: &AppHandle, next: Vec) { + let changed = { + let s = state(app); + let mut g = s.nodes.lock().unwrap_or_else(|e| e.into_inner()); + let changed = *g != next; + *g = next.clone(); + changed + }; + if !changed { + return; + } + let (closes, open) = book(app, |b| (b.prune(&next), b.panels.keys().cloned().collect::>())); + destroy(app, &closes); + for l in open { + let _ = app.emit_to(l.as_str(), MODEL_EVENT, &next); + } + if native_fallback(app) { + set_native(app); + } + } + + fn destroy(app: &AppHandle, labels: &[String]) { + for l in labels { + if let Some(w) = app.get_webview_window(l) { + let _ = w.destroy(); + } + } + } + + fn build_window(app: &AppHandle, label: &str) -> tauri::Result { + let b = WebviewWindowBuilder::new(app, label, WebviewUrl::App(PAGE.into())) + .title("storebound") + .inner_size(EST.0, EST.1) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .shadow(true) + .visible(false) + .focused(false); + #[cfg(not(target_os = "macos"))] + let b = b.transparent(true); + let window = b.build()?; + #[cfg(target_os = "macos")] + let _ = window.set_visible_on_all_workspaces(true); + let handle = app.clone(); + let me = label.to_string(); + window.on_window_event(move |event| match event { + tauri::WindowEvent::Focused(false) => { + let handle = handle.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(160)); + close_transient_unless_focused(&handle); + }); + } + tauri::WindowEvent::Moved(pos) => { + let pinned = book(&handle, |b| match b.panels.get_mut(&me) { + Some(p) if p.shown => { + p.pos = Some((pos.x as f64, pos.y as f64)); + p.pinned + } + _ => false, + }); + if pinned { + // a drag sends many moves: save once they stop + let n = state(&handle).moves.fetch_add(1, Ordering::Relaxed) + 1; + let handle = handle.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(400)); + if state(&handle).moves.load(Ordering::Relaxed) == n { + save_pins(&handle); + } + }); + } + } + _ => {} + }); + Ok(window) + } + + /// Focus left a menu window: every transient panel closes unless one of them has focus now. + fn close_transient_unless_focused(app: &AppHandle) { + let transient = book(app, |b| b.transient()); + if transient.iter().any(|l| app.get_webview_window(l).and_then(|w| w.is_focused().ok()).unwrap_or(false)) { + return; + } + book(app, |b| { + for l in &transient { + b.panels.remove(l); + } + }); + destroy(app, &transient); + } + + /// The panels cannot be shown: the native menu goes on the tray and opens once now. + fn native_popup(app: &AppHandle) { + if !state(app).native.swap(true, Ordering::Relaxed) { + crate::logs::host("warn", "tray menu panels unavailable: the native menu is used"); + } + set_native(app); + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + if let (Some(w), Ok(menu)) = (handle.get_webview_window(crate::policy::MAIN), crate::tray::native_menu(&handle, &nodes(&handle))) { + let _ = w.popup_menu(&menu); + } + }); + } + + /// A right click on the tray icon: a fresh root menu next to the icon (`anchor`: Anchor::Tray, physical px). + pub fn open_root(app: &AppHandle, anchor: Anchor) { + if native_fallback(app) { + return; // the native menu is on the tray and opens by itself + } + let (label, closes) = book(app, |b| b.open_root(anchor)); + destroy(app, &closes); + let handle = app.clone(); + std::thread::spawn(move || { + if let Err(e) = build_window(&handle, &label) { + crate::logs::host("warn", &format!("tray menu panel not built: {e}")); + book(&handle, |b| b.panels.remove(&label)); + native_popup(&handle); + return; + } + std::thread::sleep(Duration::from_millis(2500)); + if book(&handle, |b| b.panels.get(&label).map(|p| p.shown)) == Some(false) { + crate::logs::host("warn", "tray menu panel never reported ready"); + let closes = book(&handle, |b| b.close(&label)); + destroy(&handle, &closes); + native_popup(&handle); + } + }); + } + + /// Brings back the pinned panels of the last session (state.json `menuPins`). + pub fn restore(app: &AppHandle) { + let pins = app.try_state::().and_then(|s| s.saved.lock().ok().map(|st| st.menu_pins.clone())).unwrap_or_default(); + if pins.is_empty() || native_fallback(app) { + return; + } + let labels = book(app, |b| b.restore(&pins)); + let handle = app.clone(); + std::thread::spawn(move || { + for l in labels { + if build_window(&handle, &l).is_err() { + book(&handle, |b| b.panels.remove(&l)); + } + } + }); + } + + fn work_area(app: &AppHandle, x: f64, y: f64) -> Option { + let m = app.monitor_from_point(x, y).ok().flatten().or_else(|| app.primary_monitor().ok().flatten())?; + let w = m.work_area(); + Some((w.position.x as f64, w.position.y as f64, w.size.width as f64, w.size.height as f64)) + } + + fn anchor_point(a: Anchor) -> (f64, f64) { + match a { + Anchor::Point { x, y } | Anchor::At { x, y } => (x, y), + Anchor::Tray { x, y, w, h } => (x + w / 2.0, y + h / 2.0), + Anchor::Beside { right, top, .. } => (right, top), + } + } + + fn outer_rect(w: &WebviewWindow) -> Option { + let p = w.outer_position().ok()?; + let s = w.outer_size().ok()?; + Some((p.x as f64, p.y as f64, s.width as f64, s.height as f64)) + } + + fn pinned_rects(app: &AppHandle, skip: &str) -> Vec { + let labels: Vec = book(app, |b| b.panels.iter().filter(|(l, p)| p.pinned && l.as_str() != skip).map(|(l, _)| l.clone()).collect()); + labels.iter().filter_map(|l| app.get_webview_window(l).and_then(|w| outer_rect(&w))).collect() + } + + // ---- what the panel page calls (commands.rs checks the window first) ---- + + pub fn panel_state(window: &WebviewWindow) -> Option { + let app = window.app_handle(); + let p = book(app, |b| b.panels.get(window.label()).cloned())?; + Some(PanelInfo { label: window.label().into(), path: p.path, pinned: p.pinned, has_parent: p.parent.is_some(), focus_first: p.focus, nodes: nodes(app) }) + } + + /// The panel measured itself (CSS px): size it, place it inside the work area, show it the first time. + pub fn ready(window: &WebviewWindow, width: f64, height: f64) { + let app = window.app_handle().clone(); + let Some(panel) = book(&app, |b| b.panels.get(window.label()).cloned()) else { return }; + let scale = window.scale_factor().unwrap_or(1.0); + let size = ((width.clamp(120.0, 600.0) * scale).ceil(), (height.clamp(40.0, 900.0) * scale).ceil()); + let (ax, ay) = panel.pos.unwrap_or_else(|| anchor_point(panel.anchor)); + let Some(work) = work_area(&app, ax, ay) else { return }; + let (x, y) = if panel.shown { + let (x, y, _, _) = outer_rect(window).unwrap_or((ax, ay, 0.0, 0.0)); + clamp_into(x, y, size.0, size.1, work) + } else if let Some((x, y)) = panel.pos { + clamp_into(x, y, size.0, size.1, work) + } else { + place_menu(panel.anchor, size, work) + }; + let _ = window.set_size(PhysicalSize::new(size.0 as u32, size.1 as u32)); + let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32)); + if !panel.shown { + book(&app, |b| { + if let Some(p) = b.panels.get_mut(window.label()) { + p.shown = true; + p.pos = Some((x, y)); + } + }); + let _ = window.show(); + if panel.focus || (panel.parent.is_none() && !panel.pinned) { + let _ = window.set_focus(); + } + if panel.pinned { + save_pins(&app); + } + } + } + + /// Opens submenu `key` beside this panel at the row whose top is `top` (CSS px). `pinned`: torn off (Ctrl / middle + /// click), placed so it covers no other pinned panel. + pub fn open_sub(window: &WebviewWindow, key: &str, top: f64, focus: bool, pinned: bool) { + let app = window.app_handle().clone(); + let Some((wx, wy, ww, _)) = outer_rect(window) else { return }; + let scale = window.scale_factor().unwrap_or(1.0); + let anchor = Anchor::Beside { left: wx, right: wx + ww, top: wy + top.max(0.0) * scale }; + let Some((label, closes)) = book(&app, |b| b.open_sub(window.label(), key, anchor, pinned)) else { return }; + destroy(&app, &closes); + if pinned { + let others = pinned_rects(&app, &label); + if let Some(work) = work_area(&app, wx, wy) { + let est = (EST.0 * scale, EST.1 * scale); + let (x, y) = place_menu(anchor, est, work); + let spot = free_spot((x, y, est.0, est.1), &others, work); + book(&app, |b| { + if let Some(p) = b.panels.get_mut(&label) { + p.pos = Some(spot); + } + }); + } + } + book(&app, |b| { + if let Some(p) = b.panels.get_mut(&label) { + p.focus = focus; + } + }); + let handle = app.clone(); + std::thread::spawn(move || { + if build_window(&handle, &label).is_err() { + book(&handle, |b| b.panels.remove(&label)); + } + }); + } + + /// The pointer moved to a plain row: this panel's transient submenus close. + pub fn close_subs(window: &WebviewWindow) { + let app = window.app_handle().clone(); + let closes = book(&app, |b| { + let d = b.descendants(window.label()); + for l in &d { + b.panels.remove(l); + } + d + }); + destroy(&app, &closes); + } + + /// Closes this panel (Left / Esc in a submenu, X on a pinned one). + pub fn close(window: &WebviewWindow, refocus_parent: bool) { + let app = window.app_handle().clone(); + let (closes, parent, was_pinned) = book(&app, |b| { + let p = b.panels.get(window.label()).cloned(); + (b.close(window.label()), p.as_ref().and_then(|p| p.parent.clone()), p.is_some_and(|p| p.pinned)) + }); + if refocus_parent { + if let Some(w) = parent.and_then(|p| app.get_webview_window(&p)) { + let _ = w.set_focus(); + let _ = app.emit_to(w.label(), PANEL_EVENT, "focus"); + } + } + destroy(&app, &closes); + if was_pinned { + save_pins(&app); + } + } + + /// Esc on the root menu: every transient panel closes. + pub fn dismiss(app: &AppHandle) { + let closes = book(app, |b| b.dismiss()); + destroy(app, &closes); + } + + /// Pin or unpin this panel. Pinning steps it aside from the other pinned panels. + pub fn pin(window: &WebviewWindow, on: bool) { + let app = window.app_handle().clone(); + let label = window.label().to_string(); + let closes = book(&app, |b| b.pin(&label, on)); + destroy(&app, &closes); + if on { + let others = pinned_rects(&app, &label); + if let Some(me) = outer_rect(window) { + if let Some(work) = work_area(&app, me.0, me.1) { + let (x, y) = free_spot(me, &others, work); + if (x, y) != (me.0, me.1) { + let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32)); + } + book(&app, |b| { + if let Some(p) = b.panels.get_mut(&label) { + p.pos = Some((x, y)); + } + }); + } + } + } + let _ = app.emit_to(label.as_str(), PANEL_EVENT, if on { "pinned" } else { "unpinned" }); + save_pins(&app); + } + + /// A pick: the same action as the native menu. Transient panels close; pinned ones stay and update in place. + pub fn select(window: &WebviewWindow, id: String) { + let app = window.app_handle().clone(); + let pinned = book(&app, |b| b.panels.get(window.label()).map(|p| p.pinned).unwrap_or(false)); + if !pinned { + dismiss(&app); + } + let handle = app.clone(); + let _ = app.run_on_main_thread(move || crate::dispatch_menu(&handle, &id)); + } +} + +#[cfg(desktop)] +pub use runtime::*; diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index 8d6cfff..3381956 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -56,6 +56,17 @@ pub fn require_card(label: &str, url: &Url, dev: bool) -> Result<(), String> { Ok(()) } +/// The tray menu panels (`menu-1`, `menu-2`... menu_panel.rs): they may call only the menu commands. +pub fn require_menu(label: &str, url: &Url, dev: bool) -> Result<(), String> { + if !crate::menu_panel::is_menu_label(label) { + return Err(format!("not allowed from window '{label}'")); + } + if !is_local_origin(url, dev) { + return Err("not allowed from this page".into()); + } + Ok(()) +} + /// Method kinds the sidecar announces in its `session.hello` descriptors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 5d2c567..f999653 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -45,11 +45,14 @@ pub struct State { pub overrides: Overrides, #[serde(default)] pub settings: Settings, + /// Pinned tray menu panels and where they sit (menu_panel.rs); restored at start. + #[serde(default, rename = "menuPins", skip_serializing_if = "Vec::is_empty")] + pub menu_pins: Vec, } impl Default for State { fn default() -> State { - State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default() } + State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] } } } @@ -121,6 +124,7 @@ pub fn migrate(mut v: Value) -> (State, u32, Vec) { studios: list(&o, "studios", &mut notes), overrides: one(&o, "overrides", &mut notes), settings: one(&o, "settings", &mut notes), + menu_pins: list(&o, "menuPins", &mut notes), }; // two entries with one id (hand-edited or merged files): the first one wins let mut seen = std::collections::HashSet::new(); diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index e3a653f..0b23b9f 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -1,7 +1,7 @@ //! The tray: tooltip, attention icon, context menu and the "where to go" memory of the last notification. //! //! The window pushes a small summary after every store read (`tray_update`); this module turns it into a menu model -//! (pure, tested in tests/tray.rs), rebuilds the menu, sets the tooltip and swaps the icon for one with a red dot while +//! (pure, tested in tests/tray.rs), pushes it to the menu panels (menu_panel.rs), sets the tooltip and swaps the icon for one with a red dot while //! something needs the owner. Menu clicks come back as events to the window: `tray-route` (a route as JSON) and //! `tray-action` (`read`, `mute-toggle`). //! @@ -14,7 +14,9 @@ use std::time::{Duration, Instant}; use serde::Deserialize; use serde_json::Value; use tauri::image::Image; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; + +use crate::menu_panel::{from_entries, Node}; use tauri::{AppHandle, Runtime}; pub const ID_OPEN: &str = "tray-open"; @@ -88,7 +90,8 @@ pub struct TraySummary { pub enum Entry { Item { id: String, label: String, enabled: bool }, Separator, - Sub { label: String, items: Vec }, + /// `key`: the submenu's fixed key (menu_panel::sub_key), the same in every language. + Sub { key: String, label: String, items: Vec }, } fn clean(s: &str, max: usize) -> String { @@ -132,7 +135,7 @@ pub fn menu_model(s: &TraySummary) -> Vec { item(ID_OPEN, &l.open), item(ID_READ, &l.read_now), Entry::Separator, - Entry::Sub { label: clean(&l.apps, 40), items: apps }, + Entry::Sub { key: "apps".into(), label: clean(&l.apps, 40), items: apps }, Entry::Separator, item(ID_MUTE, if s.muted { &l.unmute } else { &l.mute }), item(ID_UPDATE, &l.update), @@ -233,40 +236,45 @@ pub fn take_pending() -> Option { static LAST: Mutex> = Mutex::new(None); -fn build_menu(app: &AppHandle, model: &[Entry]) -> tauri::Result> { - let menu = Menu::new(app)?; - for e in model { - match e { - Entry::Item { id, label, enabled } => menu.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?, - Entry::Separator => menu.append(&PredefinedMenuItem::separator(app)?)?, - Entry::Sub { label, items } => { - let sub = Submenu::new(app, label, true)?; - for it in items { - if let Entry::Item { id, label, enabled } = it { - sub.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?; - } - } - menu.append(&sub)?; +/// The native menu items for a panel tree (the fallback, and Linux), nested to any depth. +fn native_items(app: &AppHandle, nodes: &[Node]) -> tauri::Result>>> { + let mut out: Vec>> = Vec::new(); + for n in nodes { + match n { + Node::Item { id, label, enabled } => out.push(Box::new(MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)), + Node::Sep => out.push(Box::new(PredefinedMenuItem::separator(app)?)), + Node::Sub { label, items, .. } => { + let children = native_items(app, items)?; + let refs: Vec<&dyn IsMenuItem> = children.iter().map(|b| b.as_ref()).collect(); + out.push(Box::new(Submenu::with_items(app, label, true, &refs)?)); } } } - Ok(menu) + Ok(out) } -/// The menu shown before the window has sent anything (English words). -pub fn initial_menu(app: &AppHandle) -> tauri::Result> { - build_menu(app, &menu_model(&TraySummary::default())) +/// The native tray menu, rendered from the same tree as the panels. +pub fn native_menu(app: &AppHandle, nodes: &[Node]) -> tauri::Result> { + let items = native_items(app, nodes)?; + let refs: Vec<&dyn IsMenuItem> = items.iter().map(|b| b.as_ref()).collect(); + Menu::with_items(app, &refs) } -/// Applies a summary: tooltip, menu and icon of the tray with id `main`. Skips the work when nothing changed. -pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> { +/// The tree before the window has sent anything (English words). +pub fn initial_nodes() -> Vec { + from_entries(&menu_model(&TraySummary::default())) +} + +/// Applies a summary: tooltip and icon of the tray with id `main`, and the menu tree, which goes live to the open menu +/// panels (and to the native menu when that is in use). Skips the work when nothing changed. +pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> { let Some(tray) = app.tray_by_id("main") else { return Ok(()) }; let key = format!("{s:?}"); if LAST.lock().ok().is_some_and(|l| l.as_deref() == Some(key.as_str())) { return Ok(()); } tray.set_tooltip(Some(tooltip_text(s)))?; - tray.set_menu(Some(build_menu(app, &menu_model(s))?))?; + crate::menu_panel::set_nodes(app, from_entries(&menu_model(s))); if let Some(icon) = app.default_window_icon() { let img = if s.attention > 0 { Image::new_owned(with_attention_dot(icon.rgba(), icon.width(), icon.height()), icon.width(), icon.height()) } else { icon.clone() }; tray.set_icon(Some(img))?; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c786df1..ab2239f 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.47", + "version": "0.8.77", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", @@ -50,7 +50,7 @@ "form-action": "'none'" }, "freezePrototype": true, - "capabilities": ["main-window", "card-window"] + "capabilities": ["main-window", "card-window", "menu-window"] } }, "bundle": { diff --git a/apps/desktop/src-tauri/tests/d5.rs b/apps/desktop/src-tauri/tests/d5.rs index 6232f5b..9f2f382 100644 --- a/apps/desktop/src-tauri/tests/d5.rs +++ b/apps/desktop/src-tauri/tests/d5.rs @@ -142,3 +142,13 @@ fn background_updater_schedule_and_defer() { assert!(updates::take_kick()); assert!(!updates::take_kick()); } + +#[test] +fn startup_apps_choice_is_read_from_startup_approved() { + use storebound_desktop::startup_approved_off; + assert!(!startup_approved_off(&[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])); + assert!(!startup_approved_off(&[0x06, 0, 0, 0])); + assert!(startup_approved_off(&[0x03, 0, 0, 0, 0x20, 0xD1, 0xD3, 0xB1, 0xF0, 0xB3, 0xDC, 0x01])); + assert!(startup_approved_off(&[0x07])); + assert!(!startup_approved_off(&[])); +} diff --git a/apps/desktop/src-tauri/tests/menu_panel.rs b/apps/desktop/src-tauri/tests/menu_panel.rs new file mode 100644 index 0000000..d46cd18 --- /dev/null +++ b/apps/desktop/src-tauri/tests/menu_panel.rs @@ -0,0 +1,214 @@ +//! The tray menu panels' pure half: the tree and its stable keys, live updates, placement inside the work area, the +//! panel book (transient vs pinned, parents, restore after a restart). +use storebound_desktop::menu_panel::*; +use storebound_desktop::store::State; +use storebound_desktop::tray::{menu_model, Entry, TrayApp, TrayLabels, TraySummary}; + +const WORK: Rect = (0.0, 0.0, 1920.0, 1040.0); +const AT: Anchor = Anchor::At { x: 0.0, y: 0.0 }; + +fn summary(state: &str, apps_label: &str) -> TraySummary { + TraySummary { + apps: vec![TrayApp { key: "k1".into(), name: "First".into(), state: state.into() }, TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }], + labels: TrayLabels { apps: apps_label.into(), ..TrayLabels::default() }, + ..TraySummary::default() + } +} + +fn tree() -> Vec { + from_entries(&[ + Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }, + Entry::Separator, + Entry::Sub { key: String::new(), label: "Fleet".into(), items: vec![Entry::Sub { key: String::new(), label: "Recently lowered".into(), items: vec![] }] }, + Entry::Sub { key: String::new(), label: "Profile: Work".into(), items: vec![] }, + ]) +} + +#[test] +fn keys_ignore_live_parts_and_stay_unique() { + assert_eq!(sub_key("Profile: Work"), "profile"); + assert_eq!(sub_key("\u{25CF} First \u{00B7} ok"), "first"); + assert_eq!(sub_key("Queue (1 running, 2 queued)"), "queue"); + assert_eq!(sub_key("\u{2716}"), "sub"); + let nodes = unique_keys(vec![Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }, Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }]); + assert!(matches!(&nodes[1], Node::Sub { key, .. } if key == "a-2")); +} + +#[test] +fn find_walks_keys() { + let t = tree(); + let (items, label) = find(&t, &["fleet".into(), "recently-lowered".into()]).unwrap(); + assert_eq!((items.len(), label), (0, Some("Recently lowered"))); + assert!(find(&t, &["nope".into()]).is_none()); + assert_eq!(find(&t, &[]).unwrap().0.len(), 4); +} + +#[test] +fn the_tray_tree_keeps_its_apps_key_in_every_language_and_value() { + let en = from_entries(&menu_model(&summary("App Store: In review", "Apps"))); + let tr = from_entries(&menu_model(&summary("App Store: Live", "Uygulamalar"))); + let key = |n: &[Node]| n.iter().find_map(|n| if let Node::Sub { key, .. } = n { Some(key.clone()) } else { None }).unwrap(); + assert_eq!(key(&en), "apps"); + assert_eq!(key(&tr), "apps"); + // a live value changed: the tree differs (so it is pushed), the open Apps panel still resolves + assert_ne!(en, tr); + let (items, _) = find(&tr, &["apps".into()]).unwrap(); + assert!(matches!(&items[0], Node::Item { id, label, .. } if id == "tray-app:k1" && label.contains("Live"))); + // the same summary twice: the same tree (nothing is pushed) + assert_eq!(from_entries(&menu_model(&summary("x", "Apps"))), from_entries(&menu_model(&summary("x", "Apps")))); +} + +#[test] +fn the_tree_serializes_as_the_panel_page_reads_it() { + let v = serde_json::to_value(tree()).unwrap(); + assert_eq!(v[0], serde_json::json!({ "kind": "item", "id": "open", "label": "Open", "enabled": true })); + assert_eq!(v[1], serde_json::json!({ "kind": "sep" })); + assert_eq!(v[2]["kind"], "sub"); + assert_eq!(v[2]["key"], "fleet"); +} + +#[test] +fn root_opens_away_from_the_taskbar_and_stays_inside() { + // bottom taskbar, icon at the right: above and left of the click + let (x, y) = place_menu(Anchor::Point { x: 1900.0, y: 1060.0 }, (300.0, 400.0), WORK); + assert!(x + 300.0 <= 1920.0 - GAP && y + 400.0 <= 1040.0 - GAP && x >= 0.0 && y >= 0.0); + // macOS menu bar at the top: below it + let (_, y) = place_menu(Anchor::Point { x: 1800.0, y: 0.0 }, (300.0, 400.0), (0.0, 25.0, 1920.0, 1055.0)); + assert!(y >= 25.0 + GAP); + // a second monitor left of the primary (negative x) + let (x, _) = place_menu(Anchor::Point { x: -10.0, y: 500.0 }, (300.0, 400.0), (-1920.0, 0.0, 1920.0, 1080.0)); + assert!(x + 300.0 <= -GAP + 0.001 && x >= -1920.0); + // a panel taller than the work area: pinned to the top edge, never above it + let (_, y) = place_menu(Anchor::Point { x: 100.0, y: 100.0 }, (300.0, 2000.0), WORK); + assert_eq!(y, GAP); +} + +#[test] +fn tray_root_sits_just_above_the_icon_never_over_the_taskbar() { + // icon in a bottom taskbar (the icon is below the work area): bottom edge exactly GAP above the icon's top + let icon = Anchor::Tray { x: 1800.0, y: 1044.0, w: 32.0, h: 32.0 }; + let (_, y) = place_menu(icon, (260.0, 300.0), WORK); + assert_eq!(y + 300.0, 1040.0 - GAP); + // even when the reported work area wrongly includes the taskbar, the panel stays above the icon + let (_, y) = place_menu(icon, (260.0, 300.0), (0.0, 0.0, 1920.0, 1080.0)); + assert!(y + 300.0 <= 1044.0 - GAP); +} + +#[test] +fn submenus_open_beside_and_flip_left_at_the_edge() { + let (x, _) = place_menu(Anchor::Beside { left: 1600.0, right: 1880.0, top: 100.0 }, (280.0, 200.0), WORK); + assert!(x + 280.0 <= 1602.0); + let (x, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 100.0 }, (280.0, 200.0), WORK); + assert!((378.0..=380.0).contains(&x) && (90.0..=100.0).contains(&y)); + // a row near the bottom: the submenu moves up into the work area + let (_, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 1000.0 }, (280.0, 300.0), WORK); + assert!(y + 300.0 <= 1040.0 - GAP); +} + +#[test] +fn pinned_panels_step_aside_and_stay_inside() { + let others = [(100.0, 100.0, 280.0, 300.0), (100.0, 408.0, 280.0, 300.0)]; + let (x, y) = free_spot((120.0, 120.0, 280.0, 300.0), &others, WORK); + assert!(others.iter().all(|o| !overlaps((x, y, 280.0, 300.0), *o))); + assert!(x >= 0.0 && y >= 0.0 && x + 280.0 <= 1920.0 && y + 300.0 <= 1040.0); + // no room left below: the next column + let col = [(8.0, 8.0, 280.0, 1000.0)]; + let (x, _) = free_spot((8.0, 8.0, 280.0, 300.0), &col, WORK); + assert!(x >= 288.0); + // nothing in the way: where it was + assert_eq!(free_spot((500.0, 500.0, 280.0, 300.0), &others, WORK), (500.0, 500.0)); +} + +#[test] +fn several_menus_open_and_pinned_ones_survive_a_new_menu() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap(); + assert!(b.open_sub(&root, "fleet", AT, false).is_none(), "the same submenu again is a no-op"); + let (deep, _) = b.open_sub(&fleet, "recently-lowered", AT, false).unwrap(); + assert_eq!(b.descendants(&root).len(), 2); + // pinning the Fleet panel detaches it; the root closes, its own child stays its own + let closes = b.pin(&fleet, true); + assert_eq!(closes, vec![root.clone()]); + assert!(b.panels[&fleet].pinned && b.panels[&fleet].parent.is_none()); + assert_eq!(b.descendants(&fleet), vec![deep.clone()]); + let (root2, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + assert_eq!(closes, vec![deep]); + assert!(b.panels.contains_key(&fleet)); + assert_eq!(b.transient(), vec![root2.clone()]); + // a pick / Esc: transient panels go, the pinned one stays + assert_eq!(b.dismiss(), vec![root2]); + assert_eq!(b.panels.len(), 1); + // unpinned, it behaves like a menu again and goes with the next one + b.pin(&fleet, false); + let (_, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + assert!(closes.contains(&fleet)); +} + +#[test] +fn a_torn_off_submenu_keeps_one_pinned_copy() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (p1, _) = b.open_sub(&root, "fleet", AT, true).unwrap(); + let (p2, closes) = b.open_sub(&root, "fleet", AT, true).unwrap(); + assert!(closes.contains(&p1) && b.panels[&p2].pinned && !b.panels.contains_key(&p1)); + assert_eq!(b.panels[&p2].path, vec!["fleet".to_string()]); + // the root stays open (a tear-off is not a pick) + assert!(b.panels.contains_key(&root)); + // closing a panel that is already gone does nothing + assert!(b.close("menu-99").is_empty()); +} + +#[test] +fn a_live_update_closes_nothing_that_still_exists() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (profile, _) = b.open_sub(&root, "profile", AT, true).unwrap(); + let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap(); + assert!(b.prune(&tree()).is_empty()); + // the Fleet submenu is gone from the tree: its transient panel closes, the pinned Profile panel stays even when its + // submenu goes too (it shows that it is gone) + let smaller = from_entries(&[Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }]); + assert_eq!(b.prune(&smaller), vec![fleet]); + assert!(b.panels.contains_key(&root) && b.panels.contains_key(&profile)); +} + +#[test] +fn pins_are_saved_in_the_state_and_restored_after_a_restart() { + let mut b = Book::default(); + let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + let (p, _) = b.open_sub(&root, "apps", AT, true).unwrap(); + // not placed yet: nothing to save + assert!(b.pins().is_empty()); + b.panels.get_mut(&p).unwrap().pos = Some((40.4, 50.6)); + let pins = b.pins(); + assert_eq!(pins, vec![PinRecord { path: vec!["apps".into()], x: 40, y: 51 }]); + // through state.json + let st = State { menu_pins: pins.clone(), ..State::default() }; + let text = serde_json::to_string(&st).unwrap(); + assert!(text.contains("\"menuPins\"")); + let back: State = serde_json::from_str(&text).unwrap(); + assert_eq!(back.menu_pins, pins); + // an empty list is not written at all (older files stay as they were) + assert!(!serde_json::to_string(&State::default()).unwrap().contains("menuPins")); + let mut fresh = Book::default(); + let mut saved = back.menu_pins.clone(); + saved.push(saved[0].clone()); // a duplicate is dropped + let labels = fresh.restore(&saved); + assert_eq!(labels.len(), 1); + let r = &fresh.panels[&labels[0]]; + assert!(r.pinned && r.path == vec!["apps".to_string()] && r.pos == Some((40.0, 51.0)) && r.parent.is_none()); + // a restored pin survives the next menu + let (_, closes) = fresh.open_root(Anchor::Point { x: 0.0, y: 0.0 }); + assert!(closes.is_empty()); + let many: Vec = (0..40).map(|i| PinRecord { path: vec![format!("s{i}")], x: 0, y: 0 }).collect(); + assert_eq!(Book::default().restore(&many).len(), MAX_PINS); +} + +#[test] +fn panel_labels_are_menu_and_a_number() { + assert!(is_menu_label("menu-1") && is_menu_label("menu-204")); + for bad in ["menu-", "menu-a", "card", "main", "menu-1-2", "xmenu-1"] { + assert!(!is_menu_label(bad), "{bad}"); + } +} diff --git a/apps/desktop/src-tauri/tests/security.rs b/apps/desktop/src-tauri/tests/security.rs index 1d31b45..6c6c4c6 100644 --- a/apps/desktop/src-tauri/tests/security.rs +++ b/apps/desktop/src-tauri/tests/security.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; use storebound_desktop::policy::*; -use storebound_desktop::{CARD_COMMANDS, COMMANDS}; +use storebound_desktop::{CARD_COMMANDS, COMMANDS, MENU_COMMANDS}; use url::Url; fn root() -> PathBuf { @@ -26,7 +26,7 @@ fn one_capability_for_the_main_window_with_exactly_the_app_commands() { let dir = root().join("capabilities"); let mut files: Vec = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect(); files.sort(); - assert_eq!(files.len(), 2, "main and card capability files expected, found {files:?}"); + assert_eq!(files.len(), 3, "main, card and menu capability files expected, found {files:?}"); let cap = read_json(&dir.join("main.json")); assert_eq!(cap["identifier"], "main-window"); assert_eq!(cap["windows"], json!(["main"])); @@ -71,6 +71,45 @@ fn the_card_window_gets_only_the_card_commands() { assert!(require_main("card", &u("tauri://localhost/card.html"), false).is_err()); } +#[test] +fn the_menu_panels_get_only_the_menu_commands() { + let cap = read_json(&root().join("capabilities/menu.json")); + assert_eq!(cap["identifier"], "menu-window"); + assert_eq!(cap["windows"], json!(["menu-*"])); + assert!(cap.get("webviews").is_none() && cap.get("remote").is_none()); + assert_ne!(cap["local"], json!(false)); + let got: BTreeSet = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect(); + let mut want: BTreeSet = MENU_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect(); + want.insert("core:event:allow-listen".into()); + want.insert("core:event:allow-unlisten".into()); + // a pinned panel is dragged by its header (data-tauri-drag-region): only its own window + want.insert("core:window:allow-start-dragging".into()); + assert_eq!(got, want); + // the menu commands never leak into the main or card capability + for other in ["main.json", "card.json"] { + let cap = read_json(&root().join("capabilities").join(other)); + let perms = cap["permissions"].to_string(); + for c in MENU_COMMANDS { + assert!(!perms.contains(&format!("\"allow-{}\"", c.replace('_', "-"))), "{c} granted in {other}"); + } + } + let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap(); + for c in MENU_COMMANDS { + let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs")); + let body = &cmds[at..]; + let open = body.find('{').unwrap(); + assert!(body[open + 1..].trim_start().starts_with("guard_menu(&window)?;"), "{c} must start with guard_menu(&window)?"); + } + assert!(require_menu("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_ok()); + assert!(require_menu("menu-12", &u("http://tauri.localhost/tray-menu.html"), false).is_ok()); + for bad in ["main", "card", "menu-", "menu-x", "menu-1a", "menus-1"] { + assert!(require_menu(bad, &u("tauri://localhost/tray-menu.html"), false).is_err(), "{bad}"); + } + assert!(require_menu("menu-1", &u("https://example.com/tray-menu.html"), false).is_err()); + assert!(require_main("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err()); + assert!(require_card("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err()); +} + #[test] fn every_registered_command_is_in_the_list_and_nothing_else() { let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap(); @@ -81,7 +120,7 @@ fn every_registered_command_is_in_the_list_and_nothing_else() { .map(|s| s.trim().trim_start_matches("commands::").to_string()) .filter(|s| !s.is_empty()) .collect(); - assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).map(|s| s.to_string()).collect::>()); + assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_COMMANDS).map(|s| s.to_string()).collect::>()); // every command checks the caller first let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap(); for c in COMMANDS { @@ -109,7 +148,7 @@ fn csp_is_strict_and_the_config_has_no_backdoors() { assert!(!v.contains("unsafe-eval") && !v.contains("unsafe-inline") && !v.contains('*'), "{k}: {v}"); assert!(!v.contains("https:") && !v.contains("http://") || v == "ipc: http://ipc.localhost", "{k}: no remote sources: {v}"); } - assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window"])); + assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window", "menu-window"])); assert_eq!(conf["app"]["security"]["freezePrototype"], json!(true)); assert!(conf["app"].get("withGlobalTauri").is_none() || conf["app"]["withGlobalTauri"] == json!(false)); let windows = conf["app"]["windows"].as_array().unwrap(); diff --git a/apps/desktop/src-tauri/tests/tray.rs b/apps/desktop/src-tauri/tests/tray.rs index 95d5b67..47c03e8 100644 --- a/apps/desktop/src-tauri/tests/tray.rs +++ b/apps/desktop/src-tauri/tests/tray.rs @@ -23,7 +23,7 @@ fn ids(m: &[Entry]) -> Vec { fn menu_has_every_entry_in_order() { let m = menu_model(&summary()); assert_eq!(ids(&m), vec![ID_OPEN, ID_READ, ID_MUTE, ID_UPDATE, ID_SETTINGS, ID_QUIT]); - let Entry::Sub { label, items } = &m[3] else { panic!("the Apps submenu comes after the first separator") }; + let Entry::Sub { label, items, .. } = &m[3] else { panic!("the Apps submenu comes after the first separator") }; assert_eq!(label, "Apps"); assert_eq!(items.len(), 2); let Entry::Item { id, label, enabled } = &items[0] else { panic!() }; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b1b01fe..02a809d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,5 @@ -// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh), +// Shell: sidebar (apps grouped by what they need: attention, in progress, live, not on a store yet; one status line +// each; a studio filter; the workspace as a compact footer row), top bar (breadcrumbs, last read, refresh), // the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide. import { DryRunPreview } from './ui/DryRunPreview'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -6,8 +7,8 @@ import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api'; import { devSwitch } from './dev/switches'; import { type Key, t } from './i18n'; import { ago, num } from './lib/format'; -import { groupByStudio, type UnifiedApp } from './lib/identity'; -import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; +import type { UnifiedApp } from './lib/identity'; +import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, SECTION_ICON, SECTION_KEY, sectionOf, sectionsFor, subsOf, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav'; import { usePrefs } from './prefs-ctx'; import { Accounts } from './screens/Accounts'; import { AppFolders } from './screens/AppFolders'; @@ -28,16 +29,18 @@ import { Toasts } from './ui/Toasts'; import { useContextMenu } from './ui/ContextMenu'; import { appMenu } from './ui/app-menu'; import { isEditable, isItem, type MenuInput } from './ui/menu-logic'; -import { worstTone } from './ui/ds'; -import { attention } from './lib/model'; +import { attention, isGameSummary, SIDE_GROUPS, type SideGroup, sideStatus } from './lib/model'; const WS_ICON: Record = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' }; export const WS_KEY: Record = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' }; const SIDE_KEY = 'storebound.side.v1'; +const STUDIO_KEY = 'storebound.side.studio.v1'; function loadCollapsed(): string[] { try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; } } +function loadStudio(): string | null { try { return localStorage.getItem(STUDIO_KEY); } catch { return null; } } +const GROUP_KEY: Record = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' }; export function App({ onTheme }: { onTheme: () => void }) { const d = useData(); @@ -47,6 +50,8 @@ export function App({ onTheme }: { onTheme: () => void }) { const [welcome, setWelcome] = useState(false); const [started, setStarted] = useState(false); const [collapsed, setCollapsed] = useState(loadCollapsed); + const [studioFilter, setStudioFilterState] = useState(loadStudio); + const setStudioFilter = (v: string | null) => { setStudioFilterState(v); try { if (v) localStorage.setItem(STUDIO_KEY, v); else localStorage.removeItem(STUDIO_KEY); } catch { /* private mode */ } }; const exports = useExportItems(); const runExport = useRunExport(); @@ -56,16 +61,27 @@ export function App({ onTheme }: { onTheme: () => void }) { const allReads = useReadState({ tags: ['all'] }); const [showIgnored, setShowIgnored] = useShowIgnored(); const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]); - const listed = showIgnored ? [...visible, ...ignoredApps] : visible; - const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }]; - // Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the tab when the other app has it) - const sideOrder = groups.flatMap(x => x.rows.map(r => r.app)); + const gameOf = useCallback((a: UnifiedApp) => isGameSummary(d.statusFor(a)?.summary), [d]); + const tabsOf = useCallback((a: UnifiedApp) => tabsFor(!!a.project, gameOf(a)), [gameOf]); + // the sidebar: apps by what they need (attention, in progress, live, not on a store yet), one status line each + const studioOn = studioFilter && (studioFilter === 'none' || d.studios.some(s => s.id === studioFilter)) ? studioFilter : null; + const inStudio = useCallback((a: UnifiedApp) => !studioOn || (studioOn === 'none' ? !a.studio : a.studio === studioOn), [studioOn]); + const sideRows = useMemo(() => visible.filter(inStudio).map(a => { + const e = d.statusFor(a), l = d.liveFor(a); + return { app: a, st: sideStatus({ att: attention(a.key, a.name, e, l), all: l?.release.all ?? [], read: !!l || e?.release.source === 'live', missing: !!a.project?.missing, storeOnly: !a.project }) }; + }), [visible, d, inStudio]); + const groups: { id: SideGroup | 'ignored'; rows: typeof sideRows }[] = [ + ...SIDE_GROUPS.map(g => ({ id: g, rows: sideRows.filter(r => r.st.group === g) })).filter(g => g.rows.length), + ...(showIgnored && ignoredApps.length ? [{ id: 'ignored' as const, rows: ignoredApps.filter(inStudio).map(a => ({ app: a, st: { group: 'draft' as SideGroup, text: t('side.ignored'), tone: 'neutral' as const } })) }] : []), + ]; + // Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the place when the other app has it) + const sideOrder = groups.flatMap(x => (collapsed.includes(`g:${x.id}`) ? [] : x.rows.map(r => r.app))); const stepApp = useCallback((dir: 1 | -1) => { if (!sideOrder.length) return; const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1; const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!; - go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsFor(!!next.project).includes(route.tab) ? route.tab : 'releases' }); - }, [sideOrder, route, go]); + go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsOf(next).includes(route.tab) ? route.tab : 'releases' }); + }, [sideOrder, route, go, tabsOf]); const toggleGroup = (id: string) => setCollapsed(c => { const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id]; try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ } @@ -94,9 +110,12 @@ export function App({ onTheme }: { onTheme: () => void }) { }, [go, d.reload]); const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined; - const tabs = app ? tabsFor(!!app.project) : []; + const game = app ? gameOf(app) : false; + const tabs = app ? tabsOf(app) : []; + const sections = app ? sectionsFor(!!app.project, game) : []; - // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, Cmd/Ctrl+Shift+[ ] app tabs + // keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, 1-5 (no modifier) the app's sections, + // Cmd/Ctrl+Shift+[ ] the previous / next section useEffect(() => { const onKey = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey; @@ -109,15 +128,20 @@ export function App({ onTheme }: { onTheme: () => void }) { go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! }); return; } - if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && tabs.length) { + if (!mod && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key) && route.screen === 'app' && app && !isEditable(e.target as HTMLInputElement) && !document.querySelector('.ds-menu, .ds-dialog')) { + const sec = sections[Number(e.key) - 1]; + if (sec) { e.preventDefault(); go({ ...route, tab: subsOf(sec, !!app.project, game)[0]! }); } + return; + } + if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && app && sections.length) { e.preventDefault(); - const i = tabs.indexOf(route.tab) + (e.key === ']' || e.key === '}' ? 1 : -1); - go({ ...route, tab: tabs[(i + tabs.length) % tabs.length]! }); + const i = sections.indexOf(sectionOf(route.tab)) + (e.key === ']' || e.key === '}' ? 1 : -1); + go({ ...route, tab: subsOf(sections[(i + sections.length) % sections.length]!, !!app.project, game)[0]! }); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [palette, welcome, route, go, tabs, stepApp]); + }, [palette, welcome, route, go, app, sections, game, stepApp]); const closeWelcome = useCallback(() => { setWelcome(false); @@ -131,13 +155,18 @@ export function App({ onTheme }: { onTheme: () => void }) { const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') }; const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name; for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) }, - run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) }); + run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' }) }); c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) }); c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) }); c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) }); WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) })); for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) }); - if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); + // every section of the open app (1-5) and every sub view in it + if (app) sections.forEach((sec, i) => { + const subs = subsOf(sec, !!app.project, game); + c.push({ id: `sec-${sec}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])}`, icon: SECTION_ICON[sec], hint: String(i + 1), keywords: subs.map(x => t(TAB_KEY[x])).join(' '), run: () => go({ screen: 'app', appId: app.key, tab: subs[0]! }) }); + if (subs.length > 1) for (const tab of subs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); + }); // the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is // also one Ctrl+K away; opening a tab is already above if (app) { @@ -166,10 +195,10 @@ export function App({ onTheme }: { onTheme: () => void }) { c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme }); c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) }); return c; - }, [d, visible, app, tabs, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update]); + }, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]); const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }] - : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }] + : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(SECTION_KEY[sectionOf(route.tab)]), to: app ? { screen: 'app', appId: app.key, tab: subsOf(sectionOf(route.tab), !!app.project, game)[0] ?? route.tab } : undefined }, ...(app && subsOf(sectionOf(route.tab), !!app.project, game).length > 1 ? [{ label: t(TAB_KEY[route.tab]) }] : [])] : [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }]; const lastRead = d.live?.at ?? null; // the app on screen is read first by the background reader @@ -180,26 +209,19 @@ export function App({ onTheme }: { onTheme: () => void }) { const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length; const menu = useContextMenu(); - const appItem = (a: UnifiedApp) => { - const e = d.statusFor(a); - const missing = !!a.project?.missing; - const tone = missing ? 'bad' : (worstTone(attention(a.key, a.name, e, d.liveFor(a))) ?? (e ? 'ok' : null)); - const bad = missing ? 1 : (e?.errors.length ?? 0) + (e?.check?.problems.length ?? 0); - return ( -
- -
- ); - }; + const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => ( +
+ +
+ ); + const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE]; return ( @@ -213,36 +235,41 @@ export function App({ onTheme }: { onTheme: () => void }) {
- -
{d.studios.length ? t('nav.studiosLabel') : t('nav.appsLabel')} +
{t('nav.appsLabel')} {visible.length}
+ {d.studios.length > 1 && ( +
+ + {d.studios.map(s => )} +
+ )} {groups.map(g => { - const s = d.studios.find(x => x.id === g.studio) ?? null; - const gid = g.studio ?? 'none'; + const gid = `g:${g.id}`; const open = !collapsed.includes(gid); - if (!d.studios.length) return
{g.rows.map(r => appItem(r.app))}
; return ( -
- - {open &&
{g.rows.map(r => appItem(r.app))}
} + {open &&
{g.rows.map(r => appItem(r.app, r.st))}
}
); })} {!visible.length && !showIgnored &&

{t('nav.noApps')}

} {ignoredApps.length > 0 && } -
{t('nav.workspace')}
- {WORKSPACE.map((s, i) => ( - - ))} +
+
+ {WS_ROW.map((s, i) => { + const label = s === 'portfolio' ? t('nav.portfolio') : t(WS_KEY[s]); + const n = s === 'accounts' ? d.accounts.length : s === 'ignored' ? ignoredCount : 0; + return ( + + ); + })}
diff --git a/apps/desktop/src/card/Card.tsx b/apps/desktop/src/card/Card.tsx index 99b4101..4140e6a 100644 --- a/apps/desktop/src/card/Card.tsx +++ b/apps/desktop/src/card/Card.tsx @@ -18,7 +18,7 @@ export interface CardHost { get: () => Promise; onSnapshot: (fn: (s: CardSnapshot) => void) => () => void; onShown: (fn: () => void) => () => void; - action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready', arg?: string) => void; + action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready' | 'size', arg?: string) => void; } const route = (app: string, tab: 'releases' | 'reviews' | 'health') => JSON.stringify({ screen: 'app', appId: app, tab }); @@ -39,6 +39,18 @@ export function Card({ host, lang }: { host: CardHost; lang: CardLang }) { const tm = setInterval(() => setNow(Date.now()), 30_000); return () => { a(); b(); clearInterval(tm); }; }, [host]); + // the window is as tall as the card: report the height whenever it changes (the host re-anchors it above the icon) + useEffect(() => { + const el = root.current; + if (!el || typeof ResizeObserver === 'undefined') return; + let last = 0; + const ro = new ResizeObserver(() => { + const h = Math.ceil(el.getBoundingClientRect().height); + if (h > 0 && h !== last) { last = h; host.action('size', String(h)); } + }); + ro.observe(el); + return () => ro.disconnect(); + }, [host]); // every show: fade in, focus the first control (keyboard users start at Refresh) useEffect(() => { (root.current?.querySelector('button:not([disabled])') ?? root.current)?.focus(); }, [shown, !!s]); diff --git a/apps/desktop/src/card/card-api.ts b/apps/desktop/src/card/card-api.ts index 6a66afc..86b466c 100644 --- a/apps/desktop/src/card/card-api.ts +++ b/apps/desktop/src/card/card-api.ts @@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import type { CardSnapshot } from './model'; -export type CardAction = 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready'; +export type CardAction = 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready' | 'size'; export const inTauri = (): boolean => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; diff --git a/apps/desktop/src/card/card.css b/apps/desktop/src/card/card.css index c9502c8..68ca2e6 100644 --- a/apps/desktop/src/card/card.css +++ b/apps/desktop/src/card/card.css @@ -1,12 +1,12 @@ -/* The tray card (card.html): v2 language at 360 x 480, rounded, its own surface; fades in over 120 ms on every show. */ +/* The tray card (card.html): v2 language, 360 wide and as tall as its content (at most 480: the body scrolls), rounded, its own surface; fades in over 120 ms on every show. */ html:has(body.tc-page), body.tc-page { margin: 0; height: 100%; background: transparent; overflow: hidden; } body.tc-page { font: var(--ds-t-body)/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); } -body.tc-page #card { height: 100%; } +body.tc-page #card { height: auto; } body.tc-page.tc-in .tc { animation: tc-in 120ms ease-out; } :root[data-motion="reduce"] body.tc-page.tc-in .tc { animation: none; } @keyframes tc-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } -.tc { box-sizing: border-box; height: 100%; max-height: 480px; display: flex; flex-direction: column; border-radius: var(--ds-r-lg); +.tc { box-sizing: border-box; height: auto; max-height: 480px; display: flex; flex-direction: column; border-radius: var(--ds-r-lg); border: 1px solid var(--ds-stroke); background: radial-gradient(120% 60% at 100% 0%, color-mix(in srgb, var(--accent-solid) 10%, transparent), transparent 60%), var(--ds-raised); overflow: hidden; outline: none; } .tc-head { position: relative; display: flex; align-items: center; gap: 8px; padding: 14px 12px 12px 16px; border-bottom: 1px solid var(--ds-stroke); } .tc-title { flex: 1; min-width: 0; display: flex; flex-direction: column; } @@ -25,7 +25,7 @@ body.tc-page.tc-in .tc { animation: tc-in 120ms ease-out; } .tc-menu button:hover, .tc-menu button:focus-visible { background: var(--hover); outline: none; } .tc-menu hr { border: 0; border-top: 1px solid var(--ds-stroke); margin: 4px 2px; } -.tc-body { flex: 1; overflow-y: auto; padding: 6px 10px 12px; scrollbar-width: thin; } +.tc-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 10px 12px; scrollbar-width: thin; } .tc-empty { margin: 28px 16px; text-align: center; color: var(--muted); } .tc-sec { margin-top: 8px; } .tc-sec h2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 6px 6px 4px; font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); text-transform: uppercase; letter-spacing: .07em; color: var(--muted); } diff --git a/apps/desktop/src/design.css b/apps/desktop/src/design.css index fc782ca..2ac3a50 100644 --- a/apps/desktop/src/design.css +++ b/apps/desktop/src/design.css @@ -229,6 +229,19 @@ .ds-menu-k { max-width: 150px; overflow: hidden; text-overflow: ellipsis; margin-left: 18px; color: var(--faint); font-size: var(--ds-t-micro); letter-spacing: .02em; font-variant-numeric: tabular-nums; } .ds-menu-sub { width: 12px; height: 12px; color: var(--faint); margin-left: 10px; } .ds-menu-sep { height: 1px; margin: 5px 6px; background: var(--ds-stroke); } +/* menu header: the pin button (a normal menu or submenu), a pinned panel's title, unpin, close and drag handle */ +.ds-menu-head { display: flex; align-items: center; gap: 2px; height: 24px; padding: 0 0 0 9px; margin-bottom: 1px; } +.ds-menu-head.pinned { cursor: grab; touch-action: none; } +.ds-menu-head.pinned:active { cursor: grabbing; } +.ds-menu-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ds-t-micro); + font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--faint); } +.ds-menu-btn { flex: none; width: 22px; height: 22px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px; + background: transparent; color: var(--faint); cursor: pointer; } +.ds-menu-btn svg { width: 13px; height: 13px; } +.ds-menu-btn:hover, .ds-menu-btn:focus-visible { background: var(--ds-inset); color: var(--ink); outline: none; } +.ds-menu-btn.on { color: var(--accent); } +.ds-menu.pinned { border-color: color-mix(in srgb, var(--accent) 45%, var(--ds-stroke-strong)); transition: opacity 120ms var(--ease); } +.ds-menu-gone { max-width: 240px; padding: 6px 10px 8px; color: var(--muted); font-size: var(--ds-t-micro); white-space: normal; } @media (prefers-reduced-motion: reduce) { .ds-menu { transition: none; transform: none; } } :root[data-motion="reduce"] .ds-menu { transition: none; transform: none; } /* headings focused by code (dialog / wizard step titles) for screen readers: no ring */ @@ -245,3 +258,45 @@ .table-wrap .table tbody tr:hover > :first-child, .ds-table-wrap .table tbody tr:hover > :first-child { background: color-mix(in srgb, var(--tint, var(--accent-solid)) 6%, var(--ds-surface, var(--surface))); } /* Settings > Appearance > Tables: compact */ :root[data-density="compact"] .table th, :root[data-density="compact"] .table td { padding-top: 5px; padding-bottom: 5px; padding-left: 8px; padding-right: 8px; font-size: 12.5px; } + +/* ---------- App sections (docs IA): section header, next step, sub views ---------- */ +.ds-tab-k { font: 600 10px/1 var(--mono, ui-monospace, monospace); min-width: 16px; height: 16px; display: inline-grid; place-items: center; border-radius: 4px; color: var(--muted); border: 1px solid var(--ds-stroke); background: var(--ds-surface); margin-left: 2px; opacity: .75; } +.ds-tab[aria-selected="true"] .ds-tab-k { opacity: 1; } +.sec-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "t next" "subs subs"; gap: 10px 16px; align-items: center; padding: 2px 4px 4px; } +.sec-head-t { grid-area: t; min-width: 0; } +.sec-head-name { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--ds-t-h2, 18px); font-weight: 700; letter-spacing: -.01em; } +.sec-head-name svg { width: 18px; height: 18px; color: var(--tint, var(--accent-solid)); } +.sec-head-for { margin: 2px 0 0; color: var(--muted); font-size: var(--t-meta); } +.sec-next { grid-area: next; display: inline-flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; min-height: 32px; box-sizing: border-box; border-radius: var(--ds-r); border: 1px solid var(--ds-stroke); background: var(--ds-surface); font-weight: 600; font-size: var(--t-meta); max-width: 560px; } +.sec-next > svg { width: 15px; height: 15px; flex: none; } +.sec-next-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sec-next:not(:has(.btn)) { padding-right: 12px; } +.sec-next.tone-bad { background: var(--bad-bg); color: var(--bad-ink); border-color: transparent; } +.sec-next.tone-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: transparent; } +.sec-next.tone-ok > svg { color: var(--ok-solid); } +.sec-next.tone-info > svg { color: var(--accent-solid); } +.sec-next .btn { flex: none; } +.sec-subs { grid-area: subs; justify-self: start; } +.sec-subs .ed-dot { margin-left: 2px; } +@media (max-width: 1100px) { .sec-head { grid-template-columns: 1fr; grid-template-areas: "t" "next" "subs"; } .sec-next { justify-self: start; } } + +/* ---------- Sidebar: attention groups, one status line per app, workspace footer ---------- */ +.nav-app.has-line { height: auto; min-height: 40px; padding-top: 4px; padding-bottom: 4px; align-items: center; } +.nav-app .side-t { display: grid; min-width: 0; line-height: 1.2; } +.nav-app .side-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-app .side-s { font-size: 11px; font-weight: 500; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-app .side-s.bad { color: var(--bad-ink); } .nav-app .side-s.warn { color: var(--warn-ink); } .nav-app .side-s.ok { color: var(--ok-ink); } +:root[data-theme="dark"] .nav-app .side-s.bad { color: color-mix(in srgb, var(--bad-solid) 70%, white); } +:root[data-theme="dark"] .nav-app .side-s.warn { color: color-mix(in srgb, var(--warn-solid) 70%, white); } +:root[data-theme="dark"] .nav-app .side-s.ok { color: color-mix(in srgb, var(--ok-solid) 70%, white); } +.side-studios { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 6px 2px; } +.side-studios button { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 99px; font-size: 11px; font-weight: 600; color: var(--muted); border: 1px solid var(--line); } +.side-studios button[aria-pressed="true"] { color: var(--ink); background: var(--surface); box-shadow: var(--shadow-1); } +.side-studios button:focus-visible { outline: 2px solid var(--focus); } +.side-ws { display: flex; gap: 2px; padding: 6px 8px; border-top: 1px solid var(--line); } +.side-ws button { all: unset; cursor: pointer; position: relative; flex: 1; height: 30px; display: grid; place-items: center; border-radius: 7px; color: var(--muted); } +.side-ws button:hover { background: var(--hover); color: var(--ink); } +.side-ws button[aria-current="page"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-1); } +.side-ws button:focus-visible { outline: 2px solid var(--focus); } +.side-ws svg { width: 17px; height: 17px; } +.side-ws .ws-n { position: absolute; top: 2px; right: 4px; font-size: 9.5px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; } diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 4ba99d4..7e7be9e 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -430,6 +430,16 @@ const GAMES_PRISM = { play: { appId: '123456789012', resource: '123456789012 (android/app/src/main/res/values/games-ids.xml)', leaderboards: 2, achievements: 5, config: { leaderboards: 2, achievements: 5 }, changes: [] }, findings: [{ store: 'apple', level: 'ok', id: 'gc.entitlement', msg: 'com.apple.developer.game-center in ios/App/App/App.entitlements' }, { store: 'play', level: 'ok', id: 'pgs.app-id', msg: 'games_APP_ID 123456789012 matches the config' }], links: [{ label: 'Play Console: Play Games Services configuration', url: 'https://play.google.com/console/u/0/developers/4444/app/9876/play-games-services/setup' }], + items: [ + { kind: 'leaderboard', id: 'com.example.prismpuddle.best', name: 'Best score', locales: ['en', 'tr', 'ja', 'ar'], ios: true, android: true, + apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }, { locale: 'tr', action: 'keep' }, { locale: 'ja', action: 'keep' }, { locale: 'ar-SA', action: 'keep' }] }, play: { action: 'keep', changed: [] } }, + { kind: 'leaderboard', id: 'com.example.prismpuddle.daily', name: 'Daily puddle', locales: ['en', 'tr'], ios: true, android: true, + apple: { action: 'create', changed: ['referenceName'], locales: [{ locale: 'en-US', action: 'create' }, { locale: 'tr', action: 'create' }] }, play: { action: 'keep', changed: [] } }, + ...['first-drop', 'ten-levels', 'no-hints', 'rainbow', 'speedrun'].map((k, i) => ({ kind: 'achievement', id: `com.example.prismpuddle.${k}`, name: ['First drop', 'Ten levels', 'No hints', 'Rainbow', 'Speedrun'][i]!, locales: ['en', 'tr', 'ja', 'ar'], points: [10, 20, 30, 20, 20][i], hidden: k === 'speedrun', ios: true, android: true, + apple: { action: 'keep', changed: [], locales: [{ locale: 'en-US', action: 'keep' }] }, play: { action: i === 2 ? 'update' : 'keep', changed: i === 2 ? ['draft.description'] : [] } })), + ], + localeMap: { configured: { ar: 'ar-SA' }, suggested: [{ id: 'en', from: 'en-US', to: 'en-GB' }] }, + playApiDisabled: null, }; const ok = (method: string, data: unknown): ToolResult => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data }); diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3e3d6ba..181d7c2 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -153,7 +153,8 @@ export const en = { 'phase.releasedUnknown': "Couldn't check the store page", 'phase.pageUnknown': "Couldn't check the store page", 'phase.pageStale': "couldn't check the store page, as of {when}", - 'phase.reviewLive': 'In review (live: {version})', + 'phase.reviewLive': 'Not live yet (live: {version}): in review or blocked before review', + 'phase.playPending': 'Not live yet: in review or blocked before review', 'roll.publishReady': 'If the release is approved, publish it in Publishing overview ("Publish N changes"). The agent prepares it with `{cmd}`; the final press is for the owner.', 'roll.publishOpen': 'Open Publishing overview', 'phase.reviewManaged': 'In review / ready to publish (managed publishing)', @@ -2059,6 +2060,12 @@ export const en = { // context menus (ui/ContextMenu.tsx) 'cm.cut': "Cut", + 'cm.pin': "Pin: keeps this menu open", + 'cm.unpin': "Unpin", + 'cm.closePanel': "Close", + 'cm.gone': "Gone: what this menu was for is not on screen now.", + 'cm.menu': "Menu", + 'cm.tearOff': "Ctrl+click or middle-click: pin this submenu", 'cm.copy': "Copy", 'cm.paste': "Paste", 'cm.selectAll': "Select all", @@ -2079,6 +2086,13 @@ export const en = { 'cm.ignoredNoReads': "Ignored apps are not read", 'cm.ignore': "Ignore", 'acct.devId': 'Play developer id {id}', + 'acct.viaBroker': 'tokens from the credential broker', + 'acct.viaFile': 'local key file', + 'acct.grantsTitle': 'Play permissions on the credential broker', + 'acct.grantsOk': 'everything storebound needs', + 'acct.grantsMissing': 'missing: {list}', + 'acct.grantsNone': 'no permission data', + 'acct.notRegistered': 'not registered on the broker', 'acct.noDevId': 'no Play developer id', 'acct.devIdLabel': 'Play developer account id', 'acct.devIdHint': "The digits after /developers/ in the Play Console address. With it, Play Console links open each app's own page; the CLI uses it too.", @@ -2116,4 +2130,106 @@ export const en = { 'cm.copyPrincipal': "Service account / key", 'cm.copyProfile': "Profile name", 'cm.openAccounts': "Open Studios & accounts", + "sec.release": "Release", + "sec.store": "Store page", + "sec.revenue": "Revenue", + "sec.players": "Players", + "sec.health": "Health", + "sec.release.for": "Get a version to players on both stores: builds, TestFlight, review and rollout.", + "sec.store.for": "What people see on the store: texts per language, screenshots and app details.", + "sec.revenue.for": "What the app sells and for how much: in-app products, subscriptions and prices.", + "sec.players.for": "What players say, and for games what they can earn: reviews, Game Center, Play Games.", + "sec.health.for": "Is the app well and ready: crashes and ANRs, growth, checks to fix first, more tools.", + "sec.views": "{section} views", + "tab.games": "Game services", + "next.open": "Open {where}", + "next.rejected": "{version} was rejected on {store}", + "next.halted": "{version} is halted on {store}", + "next.blockers": "{count} blockers before submit", + "next.blockers_one": "1 blocker before submit", + "next.rollout": "{version} is rolling out at {share}", + "next.approved": "{version} is approved: release it", + "next.submit": "Submit {version} for review", + "next.review": "{version} is in review", + "next.managed": "{version} waits in Publishing overview (managed publishing)", + "next.upload": "Upload a first build", + "next.live": "Live: {version}", + "next.notRead": "Release state shows once the stores are read", + "next.storeLive": "The live store page, read from the stores", + "next.listingProblems": "{count} listing problems", + "next.listingProblems_one": "1 listing problem", + "next.unsaved": "{count} unsaved edits", + "next.unsaved_one": "1 unsaved edit", + "next.listingsUnread": "Listings not checked yet", + "next.storeOk": "Store page in step with the config", + "next.catalogDiff": "{count} products differ between the stores", + "next.catalogDiff_one": "1 product differs between the stores", + "next.noProducts": "No products yet: add one", + "next.products": "{products} products · {subs} subscriptions", + "next.reviews": "{count} reviews without a reply", + "next.reviews_one": "1 review without a reply", + "next.reviewsUnread": "See what players say", + "next.reviewsOk": "All reviews answered", + "next.gamesApiOff": "Play Games Services API is off", + "next.gamesBlockers": "{count} game services blockers", + "next.gamesBlockers_one": "1 game services blocker", + "next.gamesSync": "Game services: {count} changes to sync", + "next.gamesSync_one": "Game services: 1 change to sync", + "next.fixFirst": "{count} problems to fix first", + "next.fixFirst_one": "1 problem to fix first", + "next.warnings": "{count} warnings to look at", + "next.warnings_one": "1 warning to look at", + "next.healthOk": "No problems found", + "side.gAttention": "Needs attention", + "side.gProgress": "In progress", + "side.gLive": "Live", + "side.gDraft": "Not on a store yet", + "side.missing": "Folder missing", + "side.rejected": "Rejected · {what}", + "side.halted": "Halted · {what}", + "side.blockers": "{count} blockers", + "side.blockers_one": "1 blocker", + "side.rollout": "Rollout {share} · {what}", + "side.review": "In review · {what}", + "side.approved": "Approved · {what}", + "side.draft": "Draft · {what}", + "side.live": "Live · {version}", + "side.liveWarn": "Live · {version} · {count} warnings", + "side.liveWarn_one": "Live · {version} · 1 warning", + "side.storeOnly": "Store only · not read yet", + "side.notRead": "Not read yet", + "side.warnings": "{count} warnings", + "side.warnings_one": "1 warning", + "side.notOnStore": "No store version yet", + "side.ignored": "Ignored", + "side.studioFilter": "Show apps of a studio", + "side.allStudios": "All", + "games.keep": "In step", + "games.update": "Differs", + "games.create": "Missing", + "games.notOnStore": "Not on this store", + "games.notListed": "Not planned", + "games.notRead": "Not read", + "games.item": "Item", + "games.name": "Name", + "games.points": "Points", + "games.languages": "Languages", + "games.leaderboard": "Leaderboard", + "games.achievement": "Achievement", + "games.hidden": "hidden until earned", + "games.localeChanges": "Game Center languages: {list}", + "games.checkAgain": "Check again", + "games.apiOffTitle": "Play Games Services API is off.", + "games.apiOffText": "Turn on {api} in the service account's Google Cloud project, wait a minute, then check again.", + "games.itemsTitle": "Leaderboards and achievements", + "games.itemsSub": "One list in the config, mapped to both stores: each store's state against the config.", + "games.refresh": "Read again", + "games.noItems": "No leaderboards or achievements in the config yet (games.leaderboards, games.achievements).", + "games.notReadYet": "Reading game services…", + "games.images": "Images of leaderboards and achievements are added in the consoles; on Play the existing icon is kept on every sync.", + "games.localeMap": "Game Center locale map", + "games.localeMapSub": "Config language to the locale the live Game Center uses (games.localeMap).", + "games.mapSet": "Set", + "games.mapSuggested": "Suggested", + "games.mapHint": "Add to games.localeMap: {entries}", } as const; diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 8901d14..417f0dd 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -155,7 +155,8 @@ export const tr: Record = { 'phase.releasedUnknown': 'Mağaza sayfası kontrol edilemedi', 'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi', 'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla', - 'phase.reviewLive': 'İncelemede (yayında: {version})', + 'phase.reviewLive': 'Henüz yayında değil (yayında: {version}): incelemede ya da incelemeden önce takıldı', + 'phase.playPending': 'Henüz yayında değil: incelemede ya da incelemeden önce takıldı', 'roll.publishReady': 'Sürüm onaylandıysa Yayınlama özetinde yayınlayın ("Publish N changes"). Ajan `{cmd}` ile hazırlar; son basış sahibine aittir.', 'roll.publishOpen': 'Yayınlama özetini aç', 'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)', @@ -2061,6 +2062,12 @@ export const tr: Record = { // context menus (ui/ContextMenu.tsx) 'cm.cut': "Kes", + 'cm.pin': "Sabitle: menü açık kalır", + 'cm.unpin': "Sabitlemeyi kaldır", + 'cm.closePanel': "Kapat", + 'cm.gone': "Yok: bu menünün ait olduğu şey şu an ekranda değil.", + 'cm.menu': "Menü", + 'cm.tearOff': "Ctrl+tık veya orta tık: bu alt menüyü sabitle", 'cm.copy': "Kopyala", 'cm.paste': "Yapıştır", 'cm.selectAll': "Tümünü seç", @@ -2081,6 +2088,13 @@ export const tr: Record = { 'cm.ignoredNoReads': "Yok sayılan uygulamalar okunmaz", 'cm.ignore': "Yok say", 'acct.devId': 'Play geliştirici kimliği {id}', + 'acct.viaBroker': 'jetonlar kimlik aracısından', + 'acct.viaFile': 'yerel anahtar dosyası', + 'acct.grantsTitle': 'Kimlik aracısındaki Play izinleri', + 'acct.grantsOk': 'storebound için gereken her şey var', + 'acct.grantsMissing': 'eksik: {list}', + 'acct.grantsNone': 'izin bilgisi yok', + 'acct.notRegistered': 'aracıda kayıtlı değil', 'acct.noDevId': 'Play geliştirici kimliği yok', 'acct.devIdLabel': 'Play geliştirici hesabı kimliği', 'acct.devIdHint': 'Play Console adresinde /developers/ sonrasındaki rakamlar. Girilince Play Console bağlantıları her uygulamanın kendi sayfasını açar; CLI de kullanır.', @@ -2118,4 +2132,106 @@ export const tr: Record = { 'cm.copyPrincipal': "Hizmet hesabı / anahtar", 'cm.copyProfile': "Profil adı", 'cm.openAccounts': "Stüdyolar ve hesapları aç", + "sec.release": "Yayın", + "sec.store": "Mağaza sayfası", + "sec.revenue": "Gelir", + "sec.players": "Oyuncular", + "sec.health": "Sağlık", + "sec.release.for": "Bir sürümü iki mağazada oyunculara ulaştır: build, TestFlight, inceleme ve dağıtım.", + "sec.store.for": "Mağazada insanların gördüğü: dile göre metinler, ekran görüntüleri ve uygulama bilgileri.", + "sec.revenue.for": "Uygulamanın sattıkları ve fiyatları: uygulama içi ürünler, abonelikler ve fiyatlar.", + "sec.players.for": "Oyuncuların söyledikleri ve oyunlarda kazanabilecekleri: yorumlar, Game Center, Play Games.", + "sec.health.for": "Uygulama sağlıklı ve hazır mı: çökmeler ve ANR, büyüme, önce düzeltilecek kontroller, diğer araçlar.", + "sec.views": "{section} görünümleri", + "tab.games": "Oyun servisleri", + "next.open": "{where} aç", + "next.rejected": "{version}, {store} tarafından reddedildi", + "next.halted": "{version}, {store} üzerinde durduruldu", + "next.blockers": "Göndermeden önce {count} engel", + "next.blockers_one": "Göndermeden önce 1 engel", + "next.rollout": "{version} dağıtımı {share} seviyesinde", + "next.approved": "{version} onaylandı: yayınla", + "next.submit": "{version} sürümünü incelemeye gönder", + "next.review": "{version} incelemede", + "next.managed": "{version} Yayınlama özeti'nde bekliyor (yönetilen yayınlama)", + "next.upload": "İlk build'i yükle", + "next.live": "Yayında: {version}", + "next.notRead": "Yayın durumu mağazalar okununca görünür", + "next.storeLive": "Mağazalardan okunan canlı mağaza sayfası", + "next.listingProblems": "{count} mağaza metni sorunu", + "next.listingProblems_one": "1 mağaza metni sorunu", + "next.unsaved": "{count} kaydedilmemiş düzenleme", + "next.unsaved_one": "1 kaydedilmemiş düzenleme", + "next.listingsUnread": "Mağaza metinleri henüz kontrol edilmedi", + "next.storeOk": "Mağaza sayfası yapılandırmayla uyumlu", + "next.catalogDiff": "{count} ürün mağazalar arasında farklı", + "next.catalogDiff_one": "1 ürün mağazalar arasında farklı", + "next.noProducts": "Henüz ürün yok: bir tane ekle", + "next.products": "{products} ürün · {subs} abonelik", + "next.reviews": "{count} yorum yanıtsız", + "next.reviews_one": "1 yorum yanıtsız", + "next.reviewsUnread": "Oyuncuların söylediklerine bak", + "next.reviewsOk": "Tüm yorumlar yanıtlandı", + "next.gamesApiOff": "Play Games Services API kapalı", + "next.gamesBlockers": "{count} oyun servisi engeli", + "next.gamesBlockers_one": "1 oyun servisi engeli", + "next.gamesSync": "Oyun servisleri: eşitlenecek {count} değişiklik", + "next.gamesSync_one": "Oyun servisleri: eşitlenecek 1 değişiklik", + "next.fixFirst": "Önce düzeltilecek {count} sorun", + "next.fixFirst_one": "Önce düzeltilecek 1 sorun", + "next.warnings": "Bakılacak {count} uyarı", + "next.warnings_one": "Bakılacak 1 uyarı", + "next.healthOk": "Sorun bulunmadı", + "side.gAttention": "İlgi bekliyor", + "side.gProgress": "Sürüyor", + "side.gLive": "Yayında", + "side.gDraft": "Henüz mağazada değil", + "side.missing": "Klasör bulunamadı", + "side.rejected": "Reddedildi · {what}", + "side.halted": "Durduruldu · {what}", + "side.blockers": "{count} engel", + "side.blockers_one": "1 engel", + "side.rollout": "Dağıtım {share} · {what}", + "side.review": "İncelemede · {what}", + "side.approved": "Onaylandı · {what}", + "side.draft": "Taslak · {what}", + "side.live": "Yayında · {version}", + "side.liveWarn": "Yayında · {version} · {count} uyarı", + "side.liveWarn_one": "Yayında · {version} · 1 uyarı", + "side.storeOnly": "Yalnız mağaza · henüz okunmadı", + "side.notRead": "Henüz okunmadı", + "side.warnings": "{count} uyarı", + "side.warnings_one": "1 uyarı", + "side.notOnStore": "Henüz mağaza sürümü yok", + "side.ignored": "Yok sayıldı", + "side.studioFilter": "Bir stüdyonun uygulamalarını göster", + "side.allStudios": "Tümü", + "games.keep": "Uyumlu", + "games.update": "Farklı", + "games.create": "Eksik", + "games.notOnStore": "Bu mağazada değil", + "games.notListed": "Planlanmadı", + "games.notRead": "Okunmadı", + "games.item": "Öğe", + "games.name": "Ad", + "games.points": "Puan", + "games.languages": "Diller", + "games.leaderboard": "Skor tablosu", + "games.achievement": "Başarım", + "games.hidden": "kazanılana kadar gizli", + "games.localeChanges": "Game Center dilleri: {list}", + "games.checkAgain": "Yeniden kontrol et", + "games.apiOffTitle": "Play Games Services API kapalı.", + "games.apiOffText": "Hizmet hesabının Google Cloud projesinde {api} açın, bir dakika bekleyin, sonra yeniden kontrol edin.", + "games.itemsTitle": "Skor tabloları ve başarımlar", + "games.itemsSub": "Yapılandırmada tek liste, iki mağazaya eşlenir: her mağazanın yapılandırmaya göre durumu.", + "games.refresh": "Yeniden oku", + "games.noItems": "Yapılandırmada henüz skor tablosu veya başarım yok (games.leaderboards, games.achievements).", + "games.notReadYet": "Oyun servisleri okunuyor…", + "games.images": "Skor tablosu ve başarım görselleri konsollarda eklenir; Play'de mevcut simge her eşitlemede korunur.", + "games.localeMap": "Game Center dil eşlemesi", + "games.localeMapSub": "Yapılandırma dilinden canlı Game Center'ın kullandığı dile (games.localeMap).", + "games.mapSet": "Ayarlı", + "games.mapSuggested": "Önerilen", + "games.mapHint": "games.localeMap'e ekleyin: {entries}", }; diff --git a/apps/desktop/src/lib/model.ts b/apps/desktop/src/lib/model.ts index e1979c0..a104d02 100644 --- a/apps/desktop/src/lib/model.ts +++ b/apps/desktop/src/lib/model.ts @@ -2,7 +2,7 @@ // inbox stats, catalog parity matrix, the portfolio's "needs attention" list, and the command palette matcher. // "Unknown, never false": every derivation keeps "not read" apart from "none" / "ok". import { plainError } from '../../../../cli/lib/google-errors'; -import type { AppStatus, ProjectSummary } from '../api'; +import type { AppStatus, ProjectSummary, StoreRelease } from '../api'; import { t } from '../i18n'; import type { Tone } from '../../../../cli/lib/train'; @@ -138,7 +138,7 @@ export function parityMatrix(s: ProjectSummary | null, diff: CatalogDiff | null) // ---- Portfolio: needs attention ---- -export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' } +export interface Attention { app: string; projectId: string; tone: Tone; text: string; hint?: string; tab?: 'releases' | 'catalog' | 'listings' | 'health' | 'reviews' | 'testflight' | 'checks' | 'games' } export function attention(projectId: string, name: string, e: AppStatus | undefined, live: AppStatus | undefined, health?: HealthSnapshot | null): Attention[] { const out: Attention[] = []; @@ -203,3 +203,46 @@ export function hueOf(name: string): number { for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h % 360; } + +// ---- App kind and the sidebar ---- + +const PLAY_GAME_CATS = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing', 'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word']; +/** A game: profile game, a games section in the config, or a Play game category. */ +export function isGameSummary(s: ProjectSummary | null | undefined): boolean { + if (!s) return false; + return s.profile === 'game' || !!s.games || PLAY_GAME_CATS.includes(s.playRelease?.category ?? ''); +} + +export type SideGroup = 'attention' | 'progress' | 'live' | 'draft'; +export interface SideStatus { group: SideGroup; text: string; tone: Tone } + +/** One sidebar row's group and status line: the one thing worth knowing about the app (docs IA). */ +export function sideStatus(o: { att: Attention[]; all: StoreRelease[]; read: boolean; missing: boolean; storeOnly: boolean }): SideStatus { + const sn = (r: StoreRelease) => `${r.store === 'ios' ? 'iOS' : 'Play'} ${r.version ?? ''}`.trim(); + if (o.missing) return { group: 'attention', text: t('side.missing'), tone: 'bad' }; + const rej = o.all.find(r => r.phase === 'rejected'); + if (rej) return { group: 'attention', text: t('side.rejected', { what: sn(rej) }), tone: 'bad' }; + const halted = o.all.find(r => r.phase === 'halted'); + if (halted) return { group: 'attention', text: t('side.halted', { what: sn(halted) }), tone: 'bad' }; + const bad = o.att.filter(a => a.tone === 'bad').length; + if (bad) return { group: 'attention', text: t('side.blockers', { count: bad }), tone: 'bad' }; + const crash = o.att.find(a => a.tab === 'health'); + if (crash) return { group: 'attention', text: crash.text, tone: crash.tone }; + const rolling = o.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { group: 'progress', text: t('side.rollout', { share: `${Math.round((rolling.fraction ?? 0) * 100)} %`, what: sn(rolling) }), tone: 'info' }; + const review = o.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { group: 'progress', text: t('side.review', { what: sn(review) }), tone: 'info' }; + const approved = o.all.find(r => r.phase === 'approved'); + if (approved) return { group: 'progress', text: t('side.approved', { what: sn(approved) }), tone: 'info' }; + const live = o.all.filter(r => r.phase === 'live' || r.phase === 'released' || r.phase === 'rollout'); + const draft = o.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (draft && !live.length) return { group: 'progress', text: t('side.draft', { what: sn(draft) }), tone: 'neutral' }; + const warn = o.att.filter(a => a.tone === 'warn').length; + if (live.length) { + const vs = [...new Set(live.map(r => r.version).filter(Boolean))]; + return { group: 'live', text: warn ? t('side.liveWarn', { version: vs.join(' / '), count: warn }) : t('side.live', { version: vs.join(' / ') }), tone: warn ? 'warn' : 'ok' }; + } + if (!o.read) return { group: o.storeOnly ? 'live' : 'draft', text: o.storeOnly ? t('side.storeOnly') : t('side.notRead'), tone: 'neutral' }; + return { group: 'draft', text: warn ? t('side.warnings', { count: warn }) : t('side.notOnStore'), tone: warn ? 'warn' : 'neutral' }; +} +export const SIDE_GROUPS: SideGroup[] = ['attention', 'progress', 'live', 'draft']; diff --git a/apps/desktop/src/lib/sections.ts b/apps/desktop/src/lib/sections.ts new file mode 100644 index 0000000..5cad08e --- /dev/null +++ b/apps/desktop/src/lib/sections.ts @@ -0,0 +1,94 @@ +// The "what's next" line of each app section (docs IA): one sentence and the sub view it opens, from state that is +// already read (status, live releases, attention, cached reads). Pure, so tests pin the rules; a section never starts +// a read to fill it. +import type { CatalogParity, ListingsResult, StoreRelease } from '../api'; +import { t } from '../i18n'; +import type { AppSection, AppTab } from '../nav'; +import type { IconName } from '../ui/icons'; +import { type Attention, isGameSummary } from './model'; +import { share } from './format'; +import type { ProjectSummary } from '../api'; + +export type NextTone = 'bad' | 'warn' | 'ok' | 'info' | 'neutral'; +export interface SectionNext { text: string; tone: NextTone; tab: AppTab | null; icon: IconName } + +export interface GamesNext { changes: number; blockers: number; apiOff: boolean } +export interface SectionInput { + project: boolean; + att: Attention[]; + all: StoreRelease[]; + next: string | null; + summary: ProjectSummary | null; + catalog: CatalogParity | null; + /** Unsaved edits of the section's editors. */ + unsaved: number; + /** Cached reads (null: not read yet). */ + listings: ListingsResult | null; + reviews: { reply?: unknown }[] | null; + games: GamesNext | null; + /** Blockers of the last store readiness read (validate), null when never read. */ + readiness?: number | null; +} + +const storeName = (s: 'ios' | 'android') => (s === 'ios' ? 'App Store' : 'Google Play'); + +/** Problems of a listings read: file problems plus required fields missing or over the limit. */ +export function listingProblems(l: ListingsResult | null): number | null { + if (!l) return null; + let n = l.problems.length; + for (const loc of l.locales) for (const s of [loc.play, loc.apple]) for (const f of s?.fields ?? []) if (f.state === 'missing' || f.state === 'over') n++; + return n; +} + +export function sectionNext(section: AppSection, i: SectionInput): SectionNext { + const bad = i.att.filter(a => a.tone === 'bad'); + if (section === 'release') { + const rej = i.all.find(r => r.phase === 'rejected' || r.phase === 'halted'); + if (rej) return { text: t(rej.phase === 'rejected' ? 'next.rejected' : 'next.halted', { version: rej.version ?? '', store: storeName(rej.store) }), tone: 'bad', tab: 'releases', icon: 'alertCircle' }; + const checks = bad.filter(a => a.tab === 'checks').length + (i.readiness ?? 0); + if (checks) return { text: t('next.blockers', { count: checks }), tone: 'bad', tab: 'checks', icon: 'alertCircle' }; + const rolling = i.all.find(r => r.phase === 'rollout' && r.fraction !== null && r.fraction < 1); + if (rolling) return { text: t('next.rollout', { share: share(rolling.fraction ?? 0), version: rolling.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' }; + const approved = i.all.find(r => r.phase === 'approved'); + if (approved) return { text: t('next.approved', { version: approved.version ?? '' }), tone: 'info', tab: 'releases', icon: 'rocket' }; + const ready = i.all.find(r => r.phase === 'draft' || r.phase === 'testing'); + if (ready) return { text: t('next.submit', { version: ready.version ?? '' }), tone: 'info', tab: 'releases', icon: 'send' }; + const review = i.all.find(r => r.phase === 'review' || r.phase === 'sent'); + if (review) return { text: review.managed ? t('next.managed', { version: review.version ?? '' }) : t('next.review', { version: review.version ?? '' }), tone: 'neutral', tab: 'releases', icon: 'clock' }; + if (i.project && !i.next && !i.all.length) return { text: t('next.upload'), tone: 'neutral', tab: 'releases', icon: 'upload' }; + const live = i.all.find(r => r.phase === 'live' || r.phase === 'released'); + return live ? { text: t('next.live', { version: live.version ?? '' }), tone: 'ok', tab: null, icon: 'check' } : { text: t('next.notRead'), tone: 'neutral', tab: null, icon: 'clock' }; + } + if (section === 'store') { + if (!i.project) return { text: t('next.storeLive'), tone: 'neutral', tab: null, icon: 'cloud' }; + const n = listingProblems(i.listings); + if (n) return { text: t('next.listingProblems', { count: n }), tone: 'warn', tab: 'listings', icon: 'alert' }; + if (i.unsaved) return { text: t('next.unsaved', { count: i.unsaved }), tone: 'warn', tab: null, icon: 'edit' }; + return n === null ? { text: t('next.listingsUnread'), tone: 'neutral', tab: 'listings', icon: 'text' } : { text: t('next.storeOk'), tone: 'ok', tab: null, icon: 'check' }; + } + if (section === 'revenue') { + const c = i.catalog; + if (c && c.checked === 'live' && !c.ok) return { text: t('next.catalogDiff', { count: c.missingIos.length + c.missingAndroid.length + c.onlyIos.length + c.onlyAndroid.length }), tone: 'warn', tab: 'catalog', icon: 'alert' }; + const p = i.summary?.products ?? 0, s = i.summary?.subscriptions ?? 0; + if (!p && !s) return { text: t('next.noProducts'), tone: 'neutral', tab: 'catalog', icon: 'plus' }; + return { text: t('next.products', { products: p, subs: s }), tone: 'ok', tab: null, icon: 'check' }; + } + if (section === 'players') { + const open = i.reviews ? i.reviews.filter(r => !r.reply).length : null; + if (open) return { text: t('next.reviews', { count: open }), tone: 'warn', tab: 'reviews', icon: 'message' }; + if (i.games?.apiOff) return { text: t('next.gamesApiOff'), tone: 'bad', tab: 'games', icon: 'alertCircle' }; + if (i.games?.blockers) return { text: t('next.gamesBlockers', { count: i.games.blockers }), tone: 'bad', tab: 'games', icon: 'alertCircle' }; + if (i.games?.changes) return { text: t('next.gamesSync', { count: i.games.changes }), tone: 'warn', tab: 'games', icon: 'upload' }; + return open === null ? { text: t('next.reviewsUnread'), tone: 'neutral', tab: 'reviews', icon: 'message' } : { text: t('next.reviewsOk'), tone: 'ok', tab: null, icon: 'check' }; + } + const crash = i.att.find(a => a.tab === 'health'); + if (crash) return { text: crash.text, tone: crash.tone === 'bad' ? 'bad' : 'warn', tab: 'health', icon: 'activity' }; + const problems = i.att.filter(a => a.tab === 'checks'); + const fix = problems.filter(a => a.tone === 'bad').length + (i.readiness ?? 0); + if (fix) return { text: t('next.fixFirst', { count: fix }), tone: 'bad', tab: 'checks', icon: 'shield' }; + if (problems.length) return { text: t('next.warnings', { count: problems.length }), tone: 'warn', tab: 'checks', icon: 'shield' }; + return { text: t('next.healthOk'), tone: 'ok', tab: null, icon: 'check' }; +} + +/** A game: profile game, a games section in the config, or a Play game category. */ +export const isGame = (s: ProjectSummary | null | undefined): boolean => isGameSummary(s); diff --git a/apps/desktop/src/nav.ts b/apps/desktop/src/nav.ts index a6429e1..b442265 100644 --- a/apps/desktop/src/nav.ts +++ b/apps/desktop/src/nav.ts @@ -4,31 +4,58 @@ import { createContext, useContext } from 'react'; import type { Key } from './i18n'; import type { IconName } from './ui/icons'; -export const APP_TABS = ['releases', 'catalog', 'prices', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth', 'appinfo', 'checks', 'tools'] as const; +export const APP_TABS = ['releases', 'testflight', 'listings', 'art', 'appinfo', 'catalog', 'prices', 'reviews', 'games', 'health', 'growth', 'checks', 'tools'] as const; export type AppTab = (typeof APP_TABS)[number]; -/** Tabs of a store-only app: store reads only (catalog, prices, app info and checks need the app folder; listings and - * store art are the live ones; Growth shows the Product Page Optimization tests only). */ -export const STORE_TABS: readonly AppTab[] = ['releases', 'listings', 'art', 'health', 'reviews', 'testflight', 'growth']; -export const tabsFor = (hasFolder: boolean): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS); + +/** The five sections of an app, by job (docs IA): each holds sub views (the old tabs). The route keeps the sub id. */ +export const SECTIONS = ['release', 'store', 'revenue', 'players', 'health'] as const; +export type AppSection = (typeof SECTIONS)[number]; +export const SECTION_TABS: Record = { + release: ['releases', 'testflight'], store: ['listings', 'art', 'appinfo'], revenue: ['catalog', 'prices'], players: ['reviews', 'games'], health: ['health', 'growth', 'checks', 'tools'], +}; +export const sectionOf = (tab: AppTab): AppSection => SECTIONS.find(s => SECTION_TABS[s].includes(tab)) ?? 'release'; +export const SECTION_KEY: Record = { release: 'sec.release', store: 'sec.store', revenue: 'sec.revenue', players: 'sec.players', health: 'sec.health' }; +export const SECTION_ICON: Record = { release: 'rocket', store: 'image', revenue: 'coins', players: 'users', health: 'activity' }; + +/** Sub views of a store-only app: store reads only (catalog, prices, app info, checks and tools need the app folder; + * listings and store art are the live ones; Growth shows the Product Page Optimization tests only). */ +export const STORE_TABS: readonly AppTab[] = ['releases', 'testflight', 'listings', 'art', 'reviews', 'health', 'growth']; +/** The sub views an app has: the folder decides most; Game services only for games. */ +export const tabsFor = (hasFolder: boolean, game = false): readonly AppTab[] => (hasFolder ? APP_TABS : STORE_TABS).filter(x => x !== 'games' || (game && hasFolder)); +/** The sections an app has (a section with no sub view is left out: Revenue for a store-only app). */ +export const sectionsFor = (hasFolder: boolean, game = false): AppSection[] => { const tabs = tabsFor(hasFolder, game); return SECTIONS.filter(s => SECTION_TABS[s].some(x => tabs.includes(x))); }; +export const subsOf = (s: AppSection, hasFolder: boolean, game = false): AppTab[] => { const tabs = tabsFor(hasFolder, game); return SECTION_TABS[s].filter(x => tabs.includes(x)); }; export const TAB_KEY: Record = { releases: 'tab.releases', catalog: 'tab.catalog', prices: 'tab.prices', listings: 'tab.listings', art: 'tab.art', health: 'tab.health', reviews: 'tab.reviews', - testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', + testflight: 'tab.testflight', growth: 'tab.growth', appinfo: 'tab.appinfo', checks: 'tab.checks', tools: 'tab.tools', games: 'tab.games', }; export const TAB_ICON: Record = { releases: 'rocket', catalog: 'tag', prices: 'coins', listings: 'text', art: 'grid', health: 'activity', reviews: 'message', testflight: 'plane', growth: 'chart', - appinfo: 'clipboard', checks: 'shield', tools: 'sliders', + appinfo: 'clipboard', checks: 'shield', tools: 'sliders', games: 'sparkle', }; + +/** A place in an app from a link: an old tab id ("prices"), a section ("revenue", its first sub view) or + * "section/sub" ("store/art"). Unknown: Release. */ +export function placeOf(v: string | undefined | null): AppTab { + if (!v) return 'releases'; + const [a, b] = v.split('/'); + if (b && (SECTIONS as readonly string[]).includes(a!) && SECTION_TABS[a as AppSection].includes(b as AppTab)) return b as AppTab; + if (APP_TABS.includes(a as AppTab)) return a as AppTab; + if ((SECTIONS as readonly string[]).includes(a!)) return SECTION_TABS[a as AppSection][0]!; + return 'releases'; +} + export const WORKSPACE = ['apps', 'accounts', 'ignored', 'dependencies', 'settings'] as const; export type WorkspaceScreen = (typeof WORKSPACE)[number]; export type Route = { screen: 'portfolio' } | { screen: 'app'; appId: string; tab: AppTab } | { screen: WorkspaceScreen; section?: string }; -/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health tab; - * "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ +/** storebound:// deep links and the dev-only start switch ("app:health" = first app, Health section; + * "app:store/art" = a sub view; "app:prices" = an old tab id; "app:health:2" = second app; "settings:refresh" = a Settings section). `apps` are the app keys in list order. */ export function parseStart(v: string, apps: string[]): Route | null { const [screen, tab, n] = v.split(':'); const app = apps[Math.max(0, (Number(n) || 1) - 1)]; - if (screen === 'app' && app) return { screen: 'app', appId: app, tab: APP_TABS.includes(tab as AppTab) ? tab as AppTab : 'releases' }; + if (screen === 'app' && app) return { screen: 'app', appId: app, tab: placeOf(tab) }; if (screen === 'portfolio') return { screen: 'portfolio' }; if (!(WORKSPACE as readonly string[]).includes(screen ?? '')) return null; return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen }; @@ -41,7 +68,7 @@ export function parseRouteJson(v: string): Route | null { if (!o || typeof o !== 'object') return null; const r = o as Record; if (r.screen === 'portfolio') return { screen: 'portfolio' }; - if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: APP_TABS.includes(r.tab as AppTab) ? r.tab as AppTab : 'releases' } : null; + if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: placeOf(typeof r.tab === 'string' ? r.tab : null) } : null; if (typeof r.screen === 'string' && (WORKSPACE as readonly string[]).includes(r.screen)) return typeof r.section === 'string' ? { screen: r.screen as WorkspaceScreen, section: r.section } : { screen: r.screen as WorkspaceScreen }; return null; } diff --git a/apps/desktop/src/screens/Accounts.tsx b/apps/desktop/src/screens/Accounts.tsx index 7dc2f89..9a897b0 100644 --- a/apps/desktop/src/screens/Accounts.tsx +++ b/apps/desktop/src/screens/Accounts.tsx @@ -92,6 +92,7 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | {used ? ` · ${t('acct.folders', { count: used })}` : ''} {a.kind === 'play' && (a.developerId ? <> · {t('acct.devId', { id: a.developerId })} : <> · {t('acct.noDevId')})} {check?.principal ? <> · {check.principal} : null} + {a.kind === 'play' && check?.source ? ` · ${check.source === 'broker' ? t('acct.viaBroker') : t('acct.viaFile')}` : ''}
@@ -122,6 +123,22 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
)} + {check?.grants && check.grants.length > 0 && ( +
+
{t('acct.grantsTitle')}
+
    + {check.grants.map(g => ( +
  • + {g.packageName}{': '} + {!g.registered ? {t('acct.notRegistered')} + : g.missing.length ? {t('acct.grantsMissing', { list: g.missing.join(', ') })} + : g.grants ? {t('acct.grantsOk')} : {t('acct.grantsNone')}} + {g.grants && g.grants.length > 0 && {` ${g.grants.join(', ')}`}} +
  • + ))} +
+
+ )} {h?.error &&
{h.error}
}
); diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 1736d8d..150c29b 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -1,16 +1,20 @@ -// One app across both stores: header (icon, studio, ids, store state, pairing), then the tabs. A store-only app (no +// One app across both stores: header (icon, studio, ids, store state, pairing), then five sections by job (Release, +// Store page, Revenue, Players, Health; docs IA), each with a header (what it is for, the one next step) and its sub views. A store-only app (no // app folder) shows its store reads (releases, health, reviews, TestFlight) read-only, with "Link a folder" and // "Start an app folder here" to make it a full app. import { useContextMenu, useMenuButton } from '../ui/ContextMenu'; import { appMenu, playUrlFor } from '../ui/app-menu'; import { useShowIgnored } from '../lib/show-ignored'; import { useState } from 'react'; +import { GamesTab } from './tabs/Games'; +import { SectionHead } from './SectionHead'; import { api, errorText } from '../api'; import { type Key, t } from '../i18n'; import { pairCandidates, type UnifiedApp, withLink, withPair, withStudio } from '../lib/identity'; import { attention, storeNow } from '../lib/model'; import { Hero, nextStep, Tabs } from '../ui/ds'; -import { type AppTab, TAB_ICON, TAB_KEY, tabsFor, useNav } from '../nav'; +import { isGameSummary } from '../lib/model'; +import { type AppSection, type AppTab, SECTION_ICON, SECTION_KEY, SECTION_TABS, sectionOf, sectionsFor, subsOf, useNav } from '../nav'; import { useData } from '../store'; import { Icon } from '../ui/icons'; import { ErrorBoundary } from '../ui/ErrorBoundary'; @@ -65,7 +69,7 @@ function AppControls({ app }: { app: UnifiedApp }) { )} - + ); } @@ -107,11 +111,15 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { const ios = e?.app?.platforms.ios ?? (app.ios ? { bundleId: app.ios.app.bundleId, ascAppId: app.ios.app.id } : null); const android = e?.app?.platforms.android ?? (app.android ? { packageName: app.android.app.id } : null); const name = (app.project && e?.app?.name) || app.name; - const tabs = tabsFor(!!app.project); + const game = isGameSummary(e?.summary); + const sections = sectionsFor(!!app.project, game); + const section = sectionOf(tab); const ed = useEdits(); useUnsavedGuard(); const studio = d.studios.find(s => s.id === app.studio) ?? null; const setTab = (x: AppTab) => nav.go({ screen: 'app', appId: app.key, tab: x }); + const setSection = (x: AppSection) => setTab(subsOf(x, !!app.project, game)[0] ?? SECTION_TABS[x][0]!); + const editCount = (x: AppTab) => (EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0); const all = l?.release.all ?? []; const iosNow = l ? storeNow(all, 'ios').now : null, androidNow = l ? storeNow(all, 'android').now : null; const att = attention(app.key, app.name, e, l); @@ -122,7 +130,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { return (
appMenu(app, d, nav, { showIgnored }))} + {...cm(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)} badges={<> {e?.summary?.profile && app.project && {t(`profile.${e.summary.profile}` as 'profile.app')}} {!app.project && } @@ -137,19 +145,20 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {android && } } all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null} - compact={tab !== 'releases'} + compact={section !== 'release' || tab !== 'releases'} cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} /> {!app.project && } {app.project?.storeboundPin && {t('app.pinNotice', { pin: app.project.storeboundPin, app: d.info?.version ?? '' })}} {app.project?.missing ? : <>
- { - const n = EDIT_TABS[x] ? ed.count(app.project?.id ?? app.target, EDIT_TABS[x]) : 0; - return { id: x, label: t(TAB_KEY[x]), icon: TAB_ICON[x], count: att.filter(a => a.tab === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; + { + const n = subsOf(x, !!app.project, game).reduce((a, s2) => a + editCount(s2), 0); + return { id: x, label: t(SECTION_KEY[x]), icon: SECTION_ICON[x], hint: String(i + 1), count: att.filter(a => a.tab && sectionOf(a.tab) === x && a.tone === 'bad').length, dot: n > 0, dotTitle: n ? t('ed.unsaved', { count: n }) : undefined }; })} />
-
+ a + editCount(s2), 0)} editCount={editCount} onTab={setTab} /> +
{tab === 'releases' && } {tab === 'catalog' && app.project && } @@ -160,6 +169,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {tab === 'art' && !app.project && } {tab === 'health' && } {tab === 'reviews' && } + {tab === 'games' && app.project && } {tab === 'testflight' && } {tab === 'growth' && } {tab === 'appinfo' && app.project && } diff --git a/apps/desktop/src/screens/Ignored.tsx b/apps/desktop/src/screens/Ignored.tsx index 4c7ac27..fdc3167 100644 --- a/apps/desktop/src/screens/Ignored.tsx +++ b/apps/desktop/src/screens/Ignored.tsx @@ -30,7 +30,7 @@ export function Ignored() { {apps.map(a => { const s = d.studios.find(x => x.id === a.studio) ?? null; return ( -
appMenu(a, d, nav, { showIgnored: true }))}> +
appMenu(a, d, nav, { showIgnored: true }), `app:${a.key}`)}>
{a.name}{!a.project && } diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index c49926d..2a0b7c0 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -24,7 +24,7 @@ import { useExports } from '../ui/Exports'; import { Icon, StoreGlyph } from '../ui/icons'; import { nextStep, Pipeline, tintStyle, useShots, useTint } from '../ui/ds'; export { nextStep } from '../ui/ds'; -import { AppIcon, Card, Chip, Empty, Kpi, Notice, PhaseChip, Seg, Skeleton, Sparkline, StudioMark } from '../ui/kit'; +import { AppIcon, Card, Chip, Empty, Kpi, Notice, PhaseChip, phaseLabel, Seg, Skeleton, Sparkline, StudioMark } from '../ui/kit'; /** Tag of one app's offline config check. */ export function checkState(e: Pick | undefined): { text: string; tone: Tone } { @@ -72,7 +72,7 @@ function NextCell({ e }: { e: AppStatus | undefined }) { {n.version}
{n.ios && {t(`phase.${n.ios.phase}`)}} - {n.android && {t(`phase.${n.android.phase}`)}} + {n.android && {phaseLabel(n.android).text}}
); @@ -153,7 +153,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; const menu = useContextMenu(); const [showIgnored] = useShowIgnored(); return ( -
appMenu(app, d, nav, { showIgnored }))}> +
appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}> }> + {t('games.apiOffTitle')} {t('games.apiOffText', { api: s.playApiDisabled.title })} + + )} + +
r.refresh()} disabled={r.loading} aria-busy={r.loading}>{t('games.refresh')}}> + {rows.length ? + : {s ? t('games.noItems') : t('games.notReadYet')}} +

{t('games.images')}

+
+ {map && (Object.keys(map.configured).length > 0 || map.suggested.length > 0) && ( +
+
+ {Object.entries(map.configured).map(([k, v]) =>
{k}
{v} {t('games.mapSet')}
)} + {map.suggested.map(x =>
{x.id}
{x.from} → {x.to} {t('games.mapSuggested')}
)} +
+ {map.suggested.length > 0 &&

{t('games.mapHint', { entries: map.suggested.map(x => `${x.id}: '${x.to}'`).join(', ') })}

} +
+ )} + + ); +} diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index 877b9ca..cfe6af2 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -750,3 +750,21 @@ export function useRead(target: string, method: string, o: { network: boolean return { ...s, loading: !o.skip && (s.loading || st.busy), justNow: st.justNow, pausedUntil: st.pausedUntil, refresh }; } + +/** The cached value of one read (and every later result of it), without ever starting a read: for summaries such as a + * section's "what's next" line that must not cost a store call. Null until the read ran once. */ +export function useCachedRead(target: string | null, method: string, args: Record = {}): T | null { + const d = useData(); + const argsKey = JSON.stringify(args); + const [v, set] = useState(null); + useEffect(() => { + set(null); + if (!target) return; + let alive = true; + const a = JSON.parse(argsKey) as Record; + api.cached(method, target, a).then(c => { if (alive && c && c.value.data !== undefined && usable(method, c.value)) set(c.value.data as T); }, () => {}); + const un = d.onRead(readKey(target, method, a), e => { if (e.kind === 'done' && usable(method, e.result)) set(e.result.data as T); }); + return () => { alive = false; un(); }; + }, [target, method, argsKey]); // eslint-disable-line react-hooks/exhaustive-deps + return v; +} diff --git a/apps/desktop/src/styles/accounts.css b/apps/desktop/src/styles/accounts.css index 251f916..38059de 100644 --- a/apps/desktop/src/styles/accounts.css +++ b/apps/desktop/src/styles/accounts.css @@ -24,3 +24,5 @@ .scr-accounts .acct-fix { padding: 0 16px 16px; } .scr-accounts .acct-add { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 18px; align-items: start; } .scr-accounts .acct-add .form { gap: 14px; } +.scr-accounts .acct-grants ul { margin: 4px 0 0; padding-left: 18px; display: grid; gap: 2px; } +.scr-accounts .acct-grants li { overflow-wrap: anywhere; } diff --git a/apps/desktop/src/traymenu/TrayMenu.tsx b/apps/desktop/src/traymenu/TrayMenu.tsx new file mode 100644 index 0000000..ceab77d --- /dev/null +++ b/apps/desktop/src/traymenu/TrayMenu.tsx @@ -0,0 +1,166 @@ +// One tray menu panel (tray-menu.html): the root menu or one submenu, drawn with the design system's menu look. The +// window around it belongs to Rust (menu_panel.rs); the panel only measures itself and reports what the owner does. +import { AppWindowIcon } from '@phosphor-icons/react/dist/csr/AppWindow'; +import { ArrowsClockwiseIcon } from '@phosphor-icons/react/dist/csr/ArrowsClockwise'; +import { BellSlashIcon } from '@phosphor-icons/react/dist/csr/BellSlash'; +import { CaretRightIcon } from '@phosphor-icons/react/dist/csr/CaretRight'; +import { DeviceMobileIcon } from '@phosphor-icons/react/dist/csr/DeviceMobile'; +import { DownloadSimpleIcon } from '@phosphor-icons/react/dist/csr/DownloadSimple'; +import { GearSixIcon } from '@phosphor-icons/react/dist/csr/GearSix'; +import { PowerIcon } from '@phosphor-icons/react/dist/csr/Power'; +import { PushPinIcon } from '@phosphor-icons/react/dist/csr/PushPin'; +import { PushPinSlashIcon } from '@phosphor-icons/react/dist/csr/PushPinSlash'; +import { SquaresFourIcon } from '@phosphor-icons/react/dist/csr/SquaresFour'; +import { XIcon } from '@phosphor-icons/react/dist/csr/X'; +import { type KeyboardEvent, type MouseEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { filterNodes, findPath, isEnabled, isTearOff, keepActive, type MenuNode, nextIndex, type Row, rows, split } from './model'; + +export interface MenuBridge { + ready(width: number, height: number): void; + openSub(key: string, top: number, focus: boolean): void; + pinSub(key: string, top: number): void; + closeSubs(): void; + close(refocusParent: boolean): void; + dismiss(): void; + pin(on: boolean): void; + select(id: string): void; +} + +const WORDS = { + en: { title: 'storebound', pin: 'Pin (stays open)', unpin: 'Unpin', close: 'Close', gone: 'This menu is gone for now.', filter: 'Filter', none: 'Nothing matches', tearOff: 'Ctrl+click or middle-click: pin' }, + tr: { title: 'storebound', pin: 'Sabitle (açık kalır)', unpin: 'Sabitlemeyi kaldır', close: 'Kapat', gone: 'Bu menü şu an yok.', filter: 'Süz', none: 'Eşleşen yok', tearOff: 'Ctrl+tık veya orta tık: sabitle' }, +} satisfies Record<'en' | 'tr', Record>; +export type MenuLang = keyof typeof WORDS; + +const ICON: Record = { + 'tray-open': , 'tray-read': , 'tray-mute': , + 'tray-update': , 'tray-settings': , 'tray-quit': , +}; +const iconOf = (n: Row): ReactNode => (n.kind === 'sub' ? : ICON[n.id] ?? (n.id.startsWith('tray-app:') ? : null)); + +export function TrayMenu({ nodes, path, pinned, hasParent, focusFirst, bridge, lang = 'en', openKey }: { + nodes: MenuNode[]; path: string[]; pinned: boolean; hasParent: boolean; focusFirst?: boolean; bridge: MenuBridge; lang?: MenuLang; + /** The submenu shown as open (the dev demo; in the app Rust knows). */ + openKey?: string; +}) { + const w = WORDS[lang]; + const ref = useRef(null); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(-1); + const [open, setOpen] = useState(openKey ?? null); + const hover = useRef(undefined); + const found = useMemo(() => findPath(nodes, path), [nodes, path]); + const items = useMemo(() => (found ? filterNodes(found.items, query) : []), [found, query]); + const list = useMemo(() => rows(items), [items]); + const prev = useRef(list); + + // a live update keeps the highlight on the same row + useEffect(() => { + setActive(a => keepActive(prev.current, list, a)); + prev.current = list; + }, [list]); + useEffect(() => { + if (focusFirst) setActive(nextIndex(-1, 1, list.map(isEnabled))); + ref.current?.focus({ preventScroll: true }); + // only on the first render + }, []); + useEffect(() => () => window.clearTimeout(hover.current), []); + + // the window takes the panel's size (Rust places it inside the work area) + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + let last = ''; + const report = () => { + const r = el.getBoundingClientRect(); + const size = `${Math.ceil(r.width)}x${Math.ceil(r.height)}`; + if (size !== last) { last = size; bridge.ready(Math.ceil(r.width), Math.ceil(r.height)); } + }; + report(); + const ro = new ResizeObserver(report); + ro.observe(el); + return () => ro.disconnect(); + }, [bridge]); + + const rowTop = (i: number) => ref.current?.querySelector(`[data-i="${i}"]`)?.getBoundingClientRect().top ?? 0; + const openSub = (i: number, focus: boolean) => { + const n = list[i]; + if (n?.kind !== 'sub') return; + setOpen(n.key); + bridge.openSub(n.key, rowTop(i), focus); + }; + const pick = (i: number, e?: { button: number; ctrlKey: boolean; metaKey: boolean }) => { + const n = list[i]; + if (!n || !isEnabled(n)) return; + if (n.kind === 'sub') { + if (e && isTearOff(e)) { bridge.pinSub(n.key, rowTop(i)); return; } + openSub(i, !e); + return; + } + bridge.select(n.id); + }; + const back = () => (hasParent ? bridge.close(true) : pinned ? undefined : bridge.dismiss()); + + const onKey = (e: KeyboardEvent) => { + const k = e.key; + const en = list.map(isEnabled); + const stop = () => { e.preventDefault(); e.stopPropagation(); }; + if (k === 'ArrowDown') { stop(); setActive(a => nextIndex(a, 1, en)); } + else if (k === 'ArrowUp') { stop(); setActive(a => nextIndex(a, -1, en)); } + else if (k === 'Home' || k === 'PageUp') { stop(); setActive(nextIndex(-1, 1, en)); } + else if (k === 'End' || k === 'PageDown') { stop(); setActive(nextIndex(-1, -1, en)); } + else if (k === 'Enter' || k === ' ') { stop(); if (active >= 0) pick(active); } + else if (k === 'ArrowRight') { stop(); if (list[active]?.kind === 'sub') openSub(active, true); } + else if (k === 'ArrowLeft') { stop(); if (hasParent) bridge.close(true); } + else if (k === 'Escape') { stop(); if (query) setQuery(''); else if (pinned) bridge.close(false); else back(); } + else if (k === 'Backspace') { stop(); setQuery(q => q.slice(0, -1)); } + else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { stop(); setQuery(q => q + k); setActive(0); } + }; + + // a pinned panel whose submenu is gone keeps a name: its key + const title = !found ? (path[path.length - 1] ?? w.title).replace(/-/g, ' ') : found.label == null ? w.title : split(found.label).text; + const onRowMove = (i: number, n: Row) => { + if (active !== i) setActive(isEnabled(n) ? i : -1); + window.clearTimeout(hover.current); + if (n.kind === 'sub') { + if (open !== n.key) hover.current = window.setTimeout(() => openSub(i, false), 120); + } else if (open) { + hover.current = window.setTimeout(() => { setOpen(null); bridge.closeSubs(); }, 200); + } + }; + + return ( +
e.preventDefault()}> +
+ {query ? <>{query} : title} + + {pinned && } +
+ {!found &&
{w.gone}
} + {found && !list.length &&
{query ? w.none : '—'}
} + {items.map((n, idx) => { + if (n.kind === 'sep') return
; + const i = list.indexOf(n); + const { text, value } = split(n.label); + const dis = !isEnabled(n); + return ( +
onRowMove(i, n)} + onMouseDown={(e: MouseEvent) => { if (e.button === 1) e.preventDefault(); }} + onAuxClick={(e: MouseEvent) => { if (e.button === 1) pick(i, e); }} + onClick={(e: MouseEvent) => pick(i, e)}> + {iconOf(n)} + {text} + {value && {value}} + {n.kind === 'sub' && } +
+ ); + })} +
+ ); +} diff --git a/apps/desktop/src/traymenu/demo.tsx b/apps/desktop/src/traymenu/demo.tsx new file mode 100644 index 0000000..2b81adc --- /dev/null +++ b/apps/desktop/src/traymenu/demo.tsx @@ -0,0 +1,55 @@ +// Dev only (a plain browser, never in the release bundle): the tray menu panels on fixture data, as the tray would show +// them: the root menu with Apps open beside it and a pinned Apps panel, whose values change every few seconds the way a +// store read changes them (the panels update in place). +import { useEffect, useMemo, useState } from 'react'; +import type { MenuNode } from './model'; +import { type MenuBridge, type MenuLang, TrayMenu } from './TrayMenu'; + +const STATES = [ + ['App Store: In review · Play: Live', 'App Store: Ready for sale · Play: Live'], + ['Play: Rolling out 20%', 'Play: Rolling out 50%', 'Play: Rolling out 100%'], + ['App Store: Rejected', 'App Store: Rejected'], + ['App Store: Waiting for review', 'App Store: In review'], +]; +const NAMES = ['Star Dash', 'Puzzle Garden', 'Pocket Chef', 'Night Runner']; + +/** The tree tray.rs builds (menu_model), with fixture apps. */ +export function demoNodes(tick: number, lang: MenuLang): MenuNode[] { + const tr = lang === 'tr'; + const item = (id: string, label: string, enabled = true): MenuNode => ({ kind: 'item', id, label, enabled }); + return [ + item('tray-open', tr ? 'storebound\'u aç' : 'Open storebound'), + item('tray-read', tr ? 'Mağazaları şimdi oku' : 'Read the stores now'), + { kind: 'sep' }, + { kind: 'sub', key: 'apps', label: tr ? 'Uygulamalar' : 'Apps', items: NAMES.map((n, i) => item(`tray-app:k${i}`, `${n} · ${STATES[i]![tick % STATES[i]!.length]}`)) }, + { kind: 'sep' }, + item('tray-mute', tr ? 'Bildirimleri 1 saat sustur' : 'Mute notifications for 1 hour'), + item('tray-update', tr ? 'Güncellemeleri denetle' : 'Check for updates'), + item('tray-settings', tr ? 'Ayarlar' : 'Settings'), + { kind: 'sep' }, + item('tray-quit', tr ? 'storebound\'dan çık' : 'Quit storebound'), + ]; +} + +const log = (what: string) => (...a: unknown[]) => console.info('tray menu', what, ...a); +const demoBridge: MenuBridge = { ready: () => {}, openSub: log('openSub'), pinSub: log('pinSub'), closeSubs: () => {}, close: log('close'), dismiss: log('dismiss'), pin: log('pin'), select: log('select') }; + +export function Demo({ lang }: { lang: MenuLang }) { + const [tick, setTick] = useState(0); + const q = new URLSearchParams(location.search); + const still = q.has('still'); + useEffect(() => { + if (still) return; + const t = window.setInterval(() => setTick(n => n + 1), 3000); + return () => window.clearInterval(t); + }, [still]); + const nodes = useMemo(() => demoNodes(tick, lang), [tick, lang]); + return ( + <> +
+
+
+
+ + ); +} diff --git a/apps/desktop/src/traymenu/main.tsx b/apps/desktop/src/traymenu/main.tsx new file mode 100644 index 0000000..df0ba93 --- /dev/null +++ b/apps/desktop/src/traymenu/main.tsx @@ -0,0 +1,70 @@ +// The tray menu panel window's entry (tray-menu.html; one window per panel, made by menu_panel.rs). Theme and language +// from the same prefs as the main window; the tree and every live update from Rust (`menu-model`). In a plain dev +// browser it shows a demo with fixture data instead (demo.tsx, never in the release bundle). +import { useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { applyPrefs, loadPrefs, resolveLang } from '../prefs'; +import { bridge, inTauri, menuState, onModel, onPanel } from './menu-api'; +import type { MenuNode, PanelInfo } from './model'; +import { type MenuLang, TrayMenu } from './TrayMenu'; +import '../styles.css'; +import '../design.css'; +import './traymenu.css'; + +const sys = () => (typeof navigator !== 'undefined' ? navigator.language : 'en'); + +function prefs(): MenuLang { + const p = loadPrefs(); + if (import.meta.env.DEV) { + const q = new URLSearchParams(location.search); + const th = q.get('theme'), lg = q.get('lang'); + if (th === 'light' || th === 'dark') p.theme = th; + if (lg === 'en' || lg === 'tr') p.lang = lg; + } + applyPrefs(p, document.documentElement, sys()); + return resolveLang(p.lang, sys()); +} + +/** One panel window: its state from Rust, the live tree, pin changes. */ +function Panel({ lang }: { lang: MenuLang }) { + const [info, setInfo] = useState(null); + const [nodes, setNodes] = useState([]); + const [pinned, setPinned] = useState(false); + const [focusTick, setFocusTick] = useState(0); + useEffect(() => { + let live = true; + const offs = [ + onModel(setNodes), + onPanel(word => { + if (word === 'pinned') setPinned(true); + else if (word === 'unpinned') setPinned(false); + else if (word === 'focus') setFocusTick(n => n + 1); + }), + ]; + menuState().then((s: PanelInfo | null) => { + if (!live || !s) return; + setInfo(s); + setNodes(s.nodes); + setPinned(s.pinned); + }, () => {}); + return () => { live = false; for (const off of offs) off(); }; + }, []); + useEffect(() => { if (focusTick) document.querySelector('.tm-panel')?.focus(); }, [focusTick]); + if (!info) return null; + return ; +} + +void (async () => { + let lang = prefs(); + const root = createRoot(document.getElementById('menu')!); + if (!inTauri() && import.meta.env.DEV) { + const { Demo } = await import('./demo'); + document.body.classList.add('tm-demo'); + root.render(); + return; + } + const render = () => root.render(); + render(); + // theme or language changed in the main window (same origin: shared localStorage) + window.addEventListener('storage', () => { const l = prefs(); if (l !== lang) { lang = l; render(); } }); +})(); diff --git a/apps/desktop/src/traymenu/menu-api.ts b/apps/desktop/src/traymenu/menu-api.ts new file mode 100644 index 0000000..fd9896c --- /dev/null +++ b/apps/desktop/src/traymenu/menu-api.ts @@ -0,0 +1,33 @@ +// The tray menu panels' host calls (the panel page's counterpart of src/api.ts, kept apart so the panel bundle stays +// small). Only `menu-*` windows may call these (capabilities/menu.json; every command re-checks the window in Rust). +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import type { MenuNode, PanelInfo } from './model'; +import type { MenuBridge } from './TrayMenu'; + +export const inTauri = (): boolean => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; + +const call = (cmd: string, args?: Record) => { invoke(cmd, args).catch(() => {}); }; + +function sub(ev: string, fn: (p: T) => void): () => void { + const un = listen(ev, e => fn(e.payload)); + return () => { un.then(f => f()).catch(() => {}); }; +} + +/** This panel: its path into the tree, pinned or not, and the tree now. */ +export const menuState = (): Promise => invoke('menu_state').catch(() => null); +/** Every change of the tree (a store read, mute, language): the panel updates in place. */ +export const onModel = (fn: (nodes: MenuNode[]) => void) => sub('menu-model', fn); +/** Rust about this panel: `pinned`, `unpinned`, `focus`. */ +export const onPanel = (fn: (word: string) => void) => sub('menu-panel', fn); + +export const bridge: MenuBridge = { + ready: (width, height) => call('menu_ready', { width, height }), + openSub: (key, top, focus) => call('menu_open_sub', { key, top, focus }), + pinSub: (key, top) => call('menu_pin_sub', { key, top }), + closeSubs: () => call('menu_close_subs'), + close: refocusParent => call('menu_close', { refocusParent }), + dismiss: () => call('menu_dismiss'), + pin: pinned => call('menu_pin', { pinned }), + select: id => call('menu_select', { id }), +}; diff --git a/apps/desktop/src/traymenu/model.ts b/apps/desktop/src/traymenu/model.ts new file mode 100644 index 0000000..9109366 --- /dev/null +++ b/apps/desktop/src/traymenu/model.ts @@ -0,0 +1,75 @@ +// The tray menu panels' pure half (tested in tests/tray-menu.test.ts): the tree Rust sends (menu_panel.rs `Node`), +// walking it by stable submenu keys, splitting a row into its text and live value, type-to-filter, keyboard moves and +// keeping the highlighted row on the same item when a live update arrives. + +export type MenuNode = + | { kind: 'item'; id: string; label: string; enabled: boolean } + | { kind: 'sep' } + | { kind: 'sub'; key: string; label: string; items: MenuNode[] }; +export type Row = Exclude; + +/** What menu_state returns for one panel window. */ +export interface PanelInfo { + label: string; + path: string[]; + pinned: boolean; + hasParent: boolean; + focusFirst: boolean; + nodes: MenuNode[]; +} + +/** "First · App Store: In review" -> { text: 'First', value: 'App Store: In review' } (the value is right-aligned). */ +export function split(label: string): { text: string; value?: string } { + const at = label.indexOf('·'); + if (at <= 0) return { text: label.trim() }; + const value = label.slice(at + 1).trim(); + return value ? { text: label.slice(0, at).trim(), value } : { text: label.slice(0, at).trim() }; +} + +/** The items at `path` (submenu keys) and the submenu's label, or null when that submenu is gone. */ +export function findPath(nodes: readonly MenuNode[], path: readonly string[]): { items: MenuNode[]; label: string | null } | null { + let items = nodes as MenuNode[]; + let label: string | null = null; + for (const key of path) { + const next = items.find((n): n is Extract => n.kind === 'sub' && n.key === key); + if (!next) return null; + items = next.items; + label = next.label; + } + return { items, label }; +} + +/** Type-to-filter: rows whose label holds every word; separators drop out while filtering. */ +export function filterNodes(items: readonly MenuNode[], query: string): MenuNode[] { + const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean); + if (!words.length) return items as MenuNode[]; + return items.filter(n => n.kind !== 'sep' && words.every(w => n.label.toLocaleLowerCase().includes(w))); +} + +export const rows = (items: readonly MenuNode[]): Row[] => items.filter((n): n is Row => n.kind !== 'sep'); +export const isEnabled = (n: Row): boolean => n.kind === 'sub' || n.enabled; +export const rowId = (n: Row): string => (n.kind === 'sub' ? `sub:${n.key}` : n.id); + +/** The next enabled row from `current` in `dir`, wrapping; -1 when none. `current` -1 starts before the first. */ +export function nextIndex(current: number, dir: 1 | -1, enabled: readonly boolean[]): number { + const n = enabled.length; + if (!n) return -1; + const from = current < 0 ? (dir > 0 ? -1 : n) : current; + for (let step = 1; step <= n; step += 1) { + const i = (((from + dir * step) % n) + n) % n; + if (enabled[i]) return i; + } + return -1; +} + +/** A live update must not move the highlight: the same id / key in the new rows, else the same place (clamped). */ +export function keepActive(before: readonly Row[], after: readonly Row[], active: number): number { + if (active < 0 || !after.length) return -1; + const was = before[active]; + if (!was) return Math.min(active, after.length - 1); + const found = after.findIndex(n => rowId(n) === rowId(was)); + return found >= 0 ? found : Math.min(active, after.length - 1); +} + +/** Ctrl / Cmd + click or a middle click on a submenu row tears it off as a pinned panel. */ +export const isTearOff = (e: { button: number; ctrlKey: boolean; metaKey: boolean }): boolean => e.button === 1 || ((e.ctrlKey || e.metaKey) && e.button === 0); diff --git a/apps/desktop/src/traymenu/traymenu.css b/apps/desktop/src/traymenu/traymenu.css new file mode 100644 index 0000000..5bc1176 --- /dev/null +++ b/apps/desktop/src/traymenu/traymenu.css @@ -0,0 +1,28 @@ +/* The tray menu panels (tray-menu.html): the design system's menu (.ds-menu-*) in its own small window. The window is + transparent and exactly as large as the panel (menu_panel.rs sizes it from what the panel measures). */ +html:has(body.tm-page), body.tm-page { margin: 0; background: transparent; overflow: hidden; } +body.tm-page { font: var(--ds-t-body)/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); } + +.tm-panel { box-sizing: border-box; width: max-content; min-width: 230px; max-width: 380px; max-height: 860px; overflow: auto; padding: 4px 5px 5px; + border-radius: 12px; background: var(--ds-raised); border: 1px solid var(--ds-stroke-strong); + font-size: var(--ds-t-body); color: var(--ink); outline: none; user-select: none; cursor: default; } +.tm-panel.pinned { border-color: color-mix(in srgb, var(--accent) 45%, var(--ds-stroke-strong)); } +.tm-head { display: flex; align-items: center; gap: 2px; height: 28px; padding: 0 2px 0 9px; margin-bottom: 2px; } +.tm-panel.pinned .tm-head { cursor: grab; } +.tm-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ds-t-micro); + font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); } +.tm-q { text-transform: none; letter-spacing: 0; color: var(--ink); font-weight: 500; } +.tm-caret { display: inline-block; width: 1px; height: 11px; margin-left: 1px; vertical-align: -1px; background: var(--accent); } +.tm-btn { flex: none; width: 24px; height: 24px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px; + background: transparent; color: var(--faint); cursor: pointer; } +.tm-btn svg { width: 14px; height: 14px; } +.tm-btn:hover, .tm-btn:focus-visible { background: var(--ds-inset); color: var(--ink); outline: none; } +.tm-btn.on { color: var(--accent); } +.tm-row.open:not(.active) { background: var(--ds-inset); } +.tm-value { max-width: 190px; overflow: hidden; text-overflow: ellipsis; margin-left: 18px; color: var(--muted); + font-size: var(--ds-t-micro); font-variant-numeric: tabular-nums; } +.tm-gone { padding: 8px 10px 10px; color: var(--muted); font-size: var(--ds-t-micro); } + +/* the dev demo (a plain browser): several panels side by side on a desktop-like backdrop */ +body.tm-page.tm-demo { overflow: auto; min-height: 100vh; background: linear-gradient(135deg, #3b4a6b, #1d2436); } +.tm-demo-at { position: absolute; } diff --git a/apps/desktop/src/ui/ContextMenu.tsx b/apps/desktop/src/ui/ContextMenu.tsx index e54b534..c71217e 100644 --- a/apps/desktop/src/ui/ContextMenu.tsx +++ b/apps/desktop/src/ui/ContextMenu.tsx @@ -1,55 +1,94 @@ // Context menus (design system): the browser's own menu never shows. ContextMenuProvider (main.tsx) prevents every // native `contextmenu`, opens a small Cut / Copy / Paste / Select all menu on text fields and a Copy menu on selected // text, and turns Shift+F10 / the Menu key into a context menu on the focused element. Screens attach menus with -// `useContextMenu()`: `
[...entries])}>`. Keyboard: arrows, Home / End, Enter / Space, Esc, -// ArrowRight / ArrowLeft for submenus, type-ahead. Logic (placement, moves, type-ahead) in menu-logic.ts. -import { createContext, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useId, useRef, useState } from 'react'; +// `useContextMenu()`: `
[...entries], 'app:' + key)}>`. Keyboard: arrows, Home / End, Enter / Space, +// Esc, ArrowRight / ArrowLeft for submenus, type-ahead. Logic (placement, moves, type-ahead) in menu-logic.ts. +// +// Several menus at once (the same behaviour as the tray menu panels): a menu's pin button keeps it open as a floating +// panel; the pin button of a submenu, or Ctrl / Cmd / middle click on a submenu row, tears that submenu off as its own +// pinned panel. Pinned panels survive the next menu, can be dragged by their header, step aside so they never sit on +// each other, and update in place: they re-read their entries from the newest builder the screen registered (live +// sources, menu-stack.ts), so a read that changes a value never closes them. The panel stack is pure (menu-stack.ts). +import { createContext, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useContext, useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { t } from '../i18n'; import { copyText } from '../lib/crash'; import { Icon } from './icons'; import { MOD } from './kit'; import { enabled, first, isEditable, isItem, last, type MenuEntry, type MenuInput, type MenuItem, move, placeAt, placeBeside, tidy, typeahead } from './menu-logic'; +import { clampPos, closeMenu, closeUnpinned, LiveMenus, moveMenu, openMenu, pinMenu, type Pos, resolvePath, type StackEntry, tearOff, unpinMenu } from './menu-stack'; export type { MenuEntry, MenuInput, MenuItem } from './menu-logic'; -interface Open { x: number; y: number; items: MenuEntry[]; from: Element | null; keyboard: boolean } -type OpenFn = (x: number, y: number, input: MenuInput, opts?: { from?: Element | null; keyboard?: boolean }) => void; +interface OpenOpts { from?: Element | null; keyboard?: boolean; source?: string | null } +type OpenFn = (x: number, y: number, input: MenuInput, opts?: OpenOpts) => void; const Ctx = createContext(null); +/** Every screen's menu builders, newest first: what pinned panels re-read. */ +const live = new LiveMenus(); + /** Marks a native event a menu handled, so the global fallback leaves it alone. */ const HANDLED = '__sbMenu'; /** Marks the contextmenu event Shift+F10 / the Menu key sent (the menu starts on its first item). */ const BY_KEY = '__sbKey'; -/** Props that attach a menu to an element: ` + {pinned && } +
+ ); + return ( <> -
= 0 ? `${uid}-${active}` : undefined} + aria-label={pinned?.title} aria-activedescendant={active >= 0 ? `${uid}-${active}` : undefined} onContextMenu={e => e.preventDefault()}> + {head} + {gone &&
{t('cm.gone')}
} {items.map((e, i) => { if (!isItem(e)) return
; const dis = !!e.disabled; @@ -225,7 +396,7 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt
{ if (active !== i) setActive(dis ? -1 : i); @@ -233,7 +404,9 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt if (!dis && e.submenu) { if (sub?.index !== i) hoverTimer.current = window.setTimeout(() => openSub(i, false), 120); } else if (sub) hoverTimer.current = window.setTimeout(() => setSub(null), 200); }} - onClick={() => choose(i)}> + onMouseDown={ev => { if (ev.button === 1) ev.preventDefault(); }} + onAuxClick={ev => { if (ev.button === 1) choose(i, ev); }} + onClick={ev => choose(i, ev)}> {checkable ? (e.checked ? : null) : e.icon ? : null} {e.label} {e.shortcut && {e.shortcut}} @@ -243,7 +416,8 @@ function MenuList({ items, at, close, root, keyboard, onBack }: { items: MenuEnt })}
{sub && isItem(items[sub.index]) && (items[sub.index] as MenuItem).submenu && ( - { setSub(null); ref.current?.focus({ preventScroll: true }); }} /> )} diff --git a/apps/desktop/src/ui/GameServicesCard.tsx b/apps/desktop/src/ui/GameServicesCard.tsx index 3aa77cd..50ae13f 100644 --- a/apps/desktop/src/ui/GameServicesCard.tsx +++ b/apps/desktop/src/ui/GameServicesCard.tsx @@ -4,7 +4,7 @@ // (dry run -> native dialog -> apply) of `apple game-center` and `play games`; the PGS setup is a console plan. import type { Project } from '../api'; import { t } from '../i18n'; -import { useRead, useWrite } from '../store'; +import { type ReadState, useRead, useWrite } from '../store'; import { ConsolePlanButton } from './ConsolePlan'; import { StoreGlyph } from './icons'; import { Card, Chip, ErrorNotice, Skeleton } from './kit'; @@ -18,13 +18,23 @@ export interface GameServicesStatus { play: null | { appId: string | null; resource: string | null; config: Counts; changes: string[]; error?: string } & Counts; findings: Finding[]; links: { label: string; url: string }[]; + items?: GameItemRow[]; + localeMap?: { configured: Record; suggested: { id: string; from: string; to: string }[] }; + playApiDisabled?: { service: string; title: string; url: string } | null; +} +export type GameAction = 'create' | 'update' | 'keep'; +export interface GameItemRow { + kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean; ios: boolean; android: boolean; + apple: { action: GameAction; changed: string[]; locales: { locale: string; action: GameAction }[] } | null; + play: { action: GameAction; changed: string[] } | null; } const tone = (l: Finding['level']) => (l === 'error' ? 'bad' : l === 'warn' ? 'warn' : 'ok'); const yesNo = (v: boolean | null | undefined) => (v === null || v === undefined ? {t('gs.unknown')} : {v ? t('gs.yes') : t('gs.no')}); -export function GameServicesCard({ project }: { project: Project }) { - const r = useRead(project.id, 'games.status', { network: true, title: t('gs.title') }); +export function GameServicesCard({ project, read }: { project: Project; read?: ReadState }) { + const own = useRead(project.id, 'games.status', { network: true, title: t('gs.title'), skip: !!read }); + const r = read ?? own; const w = useWrite(project.id); if (!r.data) return r.error ? : ; const s = r.data; diff --git a/apps/desktop/src/ui/app-menu.ts b/apps/desktop/src/ui/app-menu.ts index d82ba47..144e0aa 100644 --- a/apps/desktop/src/ui/app-menu.ts +++ b/apps/desktop/src/ui/app-menu.ts @@ -5,7 +5,8 @@ import { t } from '../i18n'; import { copyText } from '../lib/crash'; import { withIgnored, withSideIgnored, withUnpair, type UnifiedApp } from '../lib/identity'; import { setShowIgnored } from '../lib/show-ignored'; -import { type AppTab, type Nav, TAB_ICON, TAB_KEY, tabsFor } from '../nav'; +import { type AppTab, type Nav, SECTION_ICON, SECTION_KEY, sectionsFor, subsOf, TAB_ICON, TAB_KEY } from '../nav'; +import { isGameSummary } from '../lib/model'; import type { Data } from '../store'; import type { MenuInput, MenuItem } from './menu-logic'; import { pickConsoleId, playAppUrl } from '../../../../cli/lib/console-links'; @@ -36,7 +37,12 @@ export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored: const storeName = (k: 'ios' | 'android') => (k === 'ios' ? 'App Store' : 'Google Play'); return [ { label: t('cm.open'), icon: 'arrowRight', onSelect: () => go('releases') }, - { label: t('cm.openTab'), icon: 'grid', submenu: tabsFor(!!p).map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) }, + { label: t('cm.openTab'), icon: 'grid', submenu: sectionsFor(!!p, isGameSummary(d.statusFor(app)?.summary)).map((sec, i) => { + const subs = subsOf(sec, !!p, isGameSummary(d.statusFor(app)?.summary)); + return subs.length > 1 + ? { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), submenu: subs.map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) } + : { label: t(SECTION_KEY[sec]), icon: SECTION_ICON[sec], shortcut: String(i + 1), onSelect: () => go(subs[0]!) }; + }) }, 'sep', (ios || android) && { label: t('cm.openConsole'), icon: 'external', submenu: [ ios && { label: 'App Store Connect', icon: 'external', onSelect: () => openUrl(d, ASC_APP(ios.id)) }, diff --git a/apps/desktop/src/ui/ds.tsx b/apps/desktop/src/ui/ds.tsx index b38aa8b..6460ba2 100644 --- a/apps/desktop/src/ui/ds.tsx +++ b/apps/desktop/src/ui/ds.tsx @@ -275,7 +275,7 @@ export function Toolbar({ label, children }: { label: string; children: ReactNod return
{children}
; } -export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string } +export interface TabItem { id: T; label: string; icon?: IconName; count?: number | null; dot?: boolean; dotTitle?: string; /** A keyboard hint shown in the tab ("1"). */ hint?: string } /** Pill tabs with icons and counts; arrows / Home / End move between them (roving tabindex). One row always: tabs that * do not fit go into a "More" menu at the end, the active tab always stays in the row. */ @@ -323,11 +323,12 @@ export function Tabs({ items, value, onChange, label, panelId return (
{items.map((x, i) => ( - ))} {hidden.length > 0 && ( diff --git a/apps/desktop/src/ui/icons.tsx b/apps/desktop/src/ui/icons.tsx index 6d0cb1c..d941d89 100644 --- a/apps/desktop/src/ui/icons.tsx +++ b/apps/desktop/src/ui/icons.tsx @@ -55,6 +55,8 @@ import { PauseIcon } from '@phosphor-icons/react/dist/csr/Pause'; import { PencilSimpleIcon } from '@phosphor-icons/react/dist/csr/PencilSimple'; import { PlayIcon } from '@phosphor-icons/react/dist/csr/Play'; import { PlusIcon } from '@phosphor-icons/react/dist/csr/Plus'; +import { PushPinIcon } from '@phosphor-icons/react/dist/csr/PushPin'; +import { PushPinSlashIcon } from '@phosphor-icons/react/dist/csr/PushPinSlash'; import { PulseIcon } from '@phosphor-icons/react/dist/csr/Pulse'; import { QuestionIcon } from '@phosphor-icons/react/dist/csr/Question'; import { RocketLaunchIcon } from '@phosphor-icons/react/dist/csr/RocketLaunch'; @@ -89,7 +91,7 @@ const ICONS = { more: DotsThreeIcon, gauge: GaugeIcon, eye: EyeIcon, eyeOff: EyeSlashIcon, link: LinkIcon, unlink: LinkBreakIcon, bell: BellIcon, filter: FunnelIcon, play: PlayIcon, send: PaperPlaneRightIcon, edit: PencilSimpleIcon, upload: UploadSimpleIcon, sliders: SlidersHorizontalIcon, rotate: ArrowCounterClockwiseIcon, cloud: CloudIcon, coins: CoinsIcon, chart: TrendUpIcon, flask: FlaskIcon, grid: GridFourIcon, - clipboard: ClipboardTextIcon, share: ExportIcon, lock: LockIcon, question: QuestionIcon, + clipboard: ClipboardTextIcon, share: ExportIcon, lock: LockIcon, question: QuestionIcon, pin: PushPinIcon, pinOff: PushPinSlashIcon, } satisfies Record; export type IconName = keyof typeof ICONS; diff --git a/apps/desktop/src/ui/kit.tsx b/apps/desktop/src/ui/kit.tsx index d118760..6e58bff 100644 --- a/apps/desktop/src/ui/kit.tsx +++ b/apps/desktop/src/ui/kit.tsx @@ -25,7 +25,7 @@ export function phaseLabel(r: StoreRelease | null | undefined): { text: string; const stale = r.pageStaleAt ? ` · ${t('phase.pageStale', { when: ago(r.pageStaleAt) })}` : ''; if (r.store === 'android' && r.phase === 'released') return { text: t('phase.pageUnknown') + stale, tone: 'neutral' }; if (r.store === 'android' && (r.phase === 'review' || r.phase === 'sent')) - return { text: (r.managed ? t('phase.reviewManaged') : r.liveVersion ? t('phase.reviewLive', { version: r.liveVersion }) : t('phase.review')) + stale, tone: 'warn' }; + return { text: (r.managed ? t('phase.reviewManaged') : r.liveVersion ? t('phase.reviewLive', { version: r.liveVersion }) : t('phase.playPending')) + stale, tone: 'warn' }; if (r.store === 'android' && r.phase === 'live' && stale) return { text: t('phase.live') + stale, tone: 'ok' }; if (r.phase === 'testing' && r.track) return { text: `${t('phase.testing')} · ${r.track}`, tone: 'info' }; return { text: t(`phase.${r.phase}`), tone: PHASE_TONE[r.phase] }; diff --git a/apps/desktop/src/ui/menu-stack.ts b/apps/desktop/src/ui/menu-stack.ts new file mode 100644 index 0000000..300371d --- /dev/null +++ b/apps/desktop/src/ui/menu-stack.ts @@ -0,0 +1,140 @@ +// The in-app context menus' panel stack without React or the DOM (tested in tests/menu-stack.test.ts): several menus +// open at once, a submenu chain per menu, pinned panels that survive the next menu, step aside so they never sit on +// each other and stay inside the window; plus the live sources pinned panels re-read their entries from. +import type { MenuEntry, MenuInput, MenuItem } from './menu-logic'; +import { tidy } from './menu-logic'; + +export interface Pos { x: number; y: number } + +/** One open panel: a root menu, or a submenu torn off (pinned) at `path` (submenu labels from the root). */ +export interface StackEntry { + uid: string; + /** The live source of its entries (`liveKey`), or null for a menu built once (the edit menu). */ + source: string | null; + path: readonly string[]; + pinned: boolean; + x: number; + y: number; +} + +/** Pinned panels step this far (CSS px) until no other pinned panel sits there. */ +export const OFFSET_STEP = 24; + +const same = (a: Pick, b: Pick) => + a.source !== null && a.source === b.source && a.path.length === b.path.length && a.path.every((p, i) => p === b.path[i]); + +/** Opening a menu closes only the unpinned ones. */ +export function openMenu(list: readonly StackEntry[], entry: StackEntry): StackEntry[] { + return [...list.filter(e => e.pinned), entry]; +} + +/** Outside click, Esc, a pick, scroll, blur: every unpinned panel closes (the same list when there is none). */ +export function closeUnpinned(list: readonly StackEntry[]): StackEntry[] { + return list.some(e => !e.pinned) ? list.filter(e => e.pinned) : (list as StackEntry[]); +} + +export function closeMenu(list: readonly StackEntry[], uid: string): StackEntry[] { + return list.some(e => e.uid === uid) ? list.filter(e => e.uid !== uid) : (list as StackEntry[]); +} + +/** A free spot: steps down-right until no taken spot is within a step of it. */ +export function autoOffset(taken: readonly Pos[], pos: Pos, step = OFFSET_STEP): Pos { + let next = { ...pos }; + for (let k = 0; k < 200 && taken.some(t => Math.abs(t.x - next.x) < step && Math.abs(t.y - next.y) < step); k++) next = { x: next.x + step, y: next.y + step }; + return next; +} + +const taken = (list: readonly StackEntry[], skip?: string): Pos[] => list.filter(e => e.pinned && e.uid !== skip).map(e => ({ x: e.x, y: e.y })); + +/** Pins an open panel where it is (moved off other pinned panels). A pinned copy of the same panel is replaced. */ +export function pinMenu(list: readonly StackEntry[], uid: string, pos: Pos): StackEntry[] { + const entry = list.find(e => e.uid === uid); + if (!entry || entry.pinned) return list as StackEntry[]; + const rest = list.filter(e => !(e.pinned && same(e, entry))); + const at = autoOffset(taken(rest, uid), pos); + return rest.map(e => (e.uid === uid ? { ...e, pinned: true, x: at.x, y: at.y } : e)); +} + +/** A submenu torn off its menu (pin button on a submenu, Ctrl / middle click on its row): a new pinned panel. The menu + * it came from stays open. */ +export function tearOff(list: readonly StackEntry[], entry: StackEntry, pos: Pos): StackEntry[] { + const rest = list.filter(e => !(e.pinned && same(e, entry))); + const at = autoOffset(taken(rest), pos); + return [...rest, { ...entry, pinned: true, x: at.x, y: at.y }]; +} + +/** Unpin: a root menu becomes a normal menu again (it closes with the next one); a torn-off submenu closes. */ +export function unpinMenu(list: readonly StackEntry[], uid: string): StackEntry[] { + const entry = list.find(e => e.uid === uid); + if (!entry?.pinned) return list as StackEntry[]; + if (entry.path.length > 0) return closeMenu(list, uid); + return list.map(e => (e.uid === uid ? { ...e, pinned: false } : e)); +} + +export function moveMenu(list: readonly StackEntry[], uid: string, pos: Pos): StackEntry[] { + return list.map(e => (e.uid === uid ? { ...e, x: pos.x, y: pos.y } : e)); +} + +/** Keeps a panel of `size` inside `box` (the window), `margin` from its edges. */ +export function clampPos(pos: Pos, size: { w: number; h: number }, box: { w: number; h: number }, margin = 8): Pos { + return { x: Math.max(margin, Math.min(pos.x, box.w - size.w - margin)), y: Math.max(margin, Math.min(pos.y, box.h - size.h - margin)) }; +} + +/** The entries at `path` (submenu labels) inside a menu, or null when that submenu is gone. */ +export function resolvePath(items: readonly MenuEntry[], path: readonly string[]): MenuEntry[] | null { + let cur = items as MenuEntry[]; + for (const label of path) { + const next = cur.find((e): e is MenuItem => e !== 'sep' && e.label === label && !!e.submenu); + if (!next) return null; + cur = next.submenu as MenuEntry[]; + } + return cur; +} + +// ---- live sources ---- + +/** + * The menus' live sources: every `useContextMenu` / `useMenuButton` call registers the builder it got on each render, + * so a pinned panel re-reads its entries from the newest builder (fresh data, fresh actions) and updates in place. A + * source that is gone (its row unmounted) gives null: the panel says so instead of closing. + */ +export class LiveMenus { + private builders = new Map MenuInput>(); + private subs = new Map void>>(); + private pending = new Set(); + constructor(private schedule: (fn: () => void) => void = fn => queueMicrotask(fn)) {} + + /** Registers (or replaces) the builder of `key`; its watchers hear about it once per batch. */ + set(key: string, build: () => MenuInput): void { + this.builders.set(key, build); + this.ping(key); + } + delete(key: string): void { + if (this.builders.delete(key)) this.ping(key); + } + /** Keeps only the keys of one hook (`prefix#...`) that its last render registered; `keep` empty drops them all. */ + retain(prefix: string, keep: ReadonlySet = new Set()): void { + for (const k of [...this.builders.keys()]) if (k.startsWith(`${prefix}#`) && !keep.has(k)) this.delete(k); + } + /** The entries of `key` now (tidied), or null when it is gone. */ + get(key: string): MenuEntry[] | null { + const b = this.builders.get(key); + if (!b) return null; + try { return tidy(b()); } catch { return null; } + } + has(key: string): boolean { return this.builders.has(key); } + watch(key: string, fn: () => void): () => void { + let s = this.subs.get(key); + if (!s) { s = new Set(); this.subs.set(key, s); } + s.add(fn); + return () => { s.delete(fn); if (!s.size) this.subs.delete(key); }; + } + private ping(key: string): void { + if (!this.subs.has(key) || this.pending.has(key)) return; + this.pending.add(key); + this.schedule(() => { + this.pending.delete(key); + for (const fn of [...(this.subs.get(key) ?? [])]) fn(); + }); + } +} diff --git a/apps/desktop/tests/menu-stack.test.ts b/apps/desktop/tests/menu-stack.test.ts new file mode 100644 index 0000000..b1c4b67 --- /dev/null +++ b/apps/desktop/tests/menu-stack.test.ts @@ -0,0 +1,150 @@ +// The in-app context menus' panel stack (ui/menu-stack.ts): several menus open, pinned panels that survive the next +// menu and never sit on each other, tear-offs, live sources that a pinned panel re-reads, and the wiring in +// ContextMenu.tsx that keeps it that way. +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { MenuEntry, MenuInput } from '../src/ui/menu-logic'; +import { autoOffset, clampPos, closeMenu, closeUnpinned, LiveMenus, moveMenu, OFFSET_STEP, openMenu, pinMenu, resolvePath, type StackEntry, tearOff, unpinMenu } from '../src/ui/menu-stack'; + +const e = (uid: string, extra: Partial = {}): StackEntry => ({ uid, source: `src-${uid}`, path: [], pinned: false, x: 10, y: 10, ...extra }); +const uids = (l: readonly StackEntry[]) => l.map(x => `${x.uid}${x.pinned ? '*' : ''}`); + +describe('menu stack', () => { + test('opening a menu closes only the unpinned ones', () => { + let l = openMenu([], e('a')); + l = pinMenu(l, 'a', { x: 100, y: 100 }); + l = openMenu(l, e('b')); + l = openMenu(l, e('c')); + expect(uids(l)).toEqual(['a*', 'c']); + l = closeUnpinned(l); + expect(uids(l)).toEqual(['a*']); + expect(closeUnpinned(l)).toBe(l); // nothing to close: the same list (no re-render) + }); + + test('a pinned panel steps aside from other pinned panels', () => { + let l = [e('a', { pinned: true, x: 100, y: 100 }), e('b')]; + l = pinMenu(l, 'b', { x: 104, y: 98 }); + const b = l.find(x => x.uid === 'b')!; + expect(b.pinned).toBe(true); + expect(Math.abs(b.x - 100) >= OFFSET_STEP || Math.abs(b.y - 100) >= OFFSET_STEP).toBe(true); + expect(autoOffset([{ x: 0, y: 0 }, { x: 24, y: 24 }], { x: 0, y: 0 })).toEqual({ x: 48, y: 48 }); + expect(autoOffset([], { x: 5, y: 5 })).toEqual({ x: 5, y: 5 }); + }); + + test('pinning the same panel again replaces the old pinned copy', () => { + let l = [e('a', { pinned: true, source: 's', path: ['Copy'] }), e('b', { source: 's', path: ['Copy'] })]; + l = pinMenu(l, 'b', { x: 300, y: 300 }); + expect(uids(l)).toEqual(['b*']); + // a menu without a live source (the edit menu) is never "the same" as another + l = [e('a', { pinned: true, source: null }), e('b', { source: null })]; + expect(uids(pinMenu(l, 'b', { x: 300, y: 300 }))).toEqual(['a*', 'b*']); + }); + + test('a torn-off submenu is its own pinned panel and the menu it came from stays open', () => { + let l = openMenu([], e('a')); + l = tearOff(l, e('t', { path: ['Open tab'] }), { x: 50, y: 50 }); + expect(uids(l)).toEqual(['a', 't*']); + // the same submenu torn off again: one copy + l = tearOff(l, e('t2', { path: ['Open tab'], source: 'src-t' }), { x: 50, y: 50 }); + expect(uids(l)).toEqual(['a', 't2*']); + }); + + test('unpin: a root menu becomes a normal menu again, a torn-off submenu closes', () => { + let l = [e('a', { pinned: true }), e('t', { pinned: true, path: ['Copy'] })]; + l = unpinMenu(l, 'a'); + expect(uids(l)).toEqual(['a', 't*']); + l = unpinMenu(l, 't'); + expect(uids(l)).toEqual(['a']); + expect(uids(closeMenu(l, 'a'))).toEqual([]); + expect(closeMenu(l, 'zz')).toBe(l); + }); + + test('a moved panel keeps the move; positions stay inside the window', () => { + const l = moveMenu([e('a', { pinned: true })], 'a', { x: 400, y: 20 }); + expect(l[0]).toMatchObject({ x: 400, y: 20 }); + expect(clampPos({ x: 1900, y: -40 }, { w: 240, h: 300 }, { w: 1280, h: 800 })).toEqual({ x: 1280 - 240 - 8, y: 8 }); + expect(clampPos({ x: 100, y: 100 }, { w: 240, h: 300 }, { w: 1280, h: 800 })).toEqual({ x: 100, y: 100 }); + }); + + test('resolvePath walks submenu labels; a gone submenu gives null', () => { + const items: MenuEntry[] = [{ label: 'Open' }, 'sep', { label: 'Copy', submenu: [{ label: 'Name' }, { label: 'More', submenu: [{ label: 'Deep' }] }] }]; + expect(resolvePath(items, [])).toBe(items); + expect(resolvePath(items, ['Copy', 'More'])!.map(x => (x === 'sep' ? '-' : x.label))).toEqual(['Deep']); + expect(resolvePath(items, ['Open'])).toBeNull(); // not a submenu + expect(resolvePath(items, ['Gone'])).toBeNull(); + }); +}); + +describe('live sources', () => { + const sync = () => { + const queue: (() => void)[] = []; + return { live: new LiveMenus(fn => queue.push(fn)), flush: () => { while (queue.length) queue.shift()!(); } }; + }; + + test('a pinned panel hears once per batch and re-reads the newest builder', () => { + const { live, flush } = sync(); + let value = 1; + const build = (): MenuInput => [{ label: `Rollout ${value}%` }, false]; + live.set('h#0', build); + let heard = 0; + const off = live.watch('h#0', () => heard++); + value = 20; + live.set('h#0', build); + live.set('h#0', build); + expect(heard).toBe(0); + flush(); + expect(heard).toBe(1); + expect(live.get('h#0')!.map(x => (x === 'sep' ? '-' : x.label))).toEqual(['Rollout 20%']); + off(); + live.set('h#0', build); + flush(); + expect(heard).toBe(1); + }); + + test('keys a render no longer makes are dropped and their panels hear it (gone, not closed)', () => { + const { live, flush } = sync(); + live.set('h#0', () => [{ label: 'A' }]); + live.set('h#app:k1', () => [{ label: 'B' }]); + live.set('other#0', () => [{ label: 'C' }]); + let heard = 0; + live.watch('h#app:k1', () => heard++); + live.retain('h', new Set(['h#0'])); + flush(); + expect(heard).toBe(1); + expect(live.get('h#app:k1')).toBeNull(); + expect(live.has('h#0') && live.has('other#0')).toBe(true); + live.retain('h'); + expect(live.has('h#0')).toBe(false); + expect(live.has('other#0')).toBe(true); + }); + + test('a builder that throws reads as gone', () => { + const { live } = sync(); + live.set('x#0', () => { throw new Error('boom'); }); + expect(live.get('x#0')).toBeNull(); + expect(live.get('missing')).toBeNull(); + }); +}); + +describe('wiring', () => { + const s = readFileSync(join(import.meta.dir, '../src/ui/ContextMenu.tsx'), 'utf8'); + test('menus register live sources and pinned panels re-read them', () => { + expect(s).toMatch(/live\.set\(source, build\)/); + expect(s).toMatch(/live\.watch\(entry\.source, bump\)/); + expect(s).toMatch(/resolvePath\(root, entry\.path\)/); + }); + test('an outside click closes only the normal menus; a pick in a pinned panel keeps it', () => { + expect(s).toMatch(/closeUnpinned\(s\)/); + expect(s).toMatch(/done=\{\(\) => closeTransient\(false\)\}/); + expect(s).toMatch(/ev\.button === 1 \|\| ev\.ctrlKey \|\| ev\.metaKey/); + }); + test('the app menus carry the app key, so a pinned panel follows the app when rows reorder', () => { + for (const f of ['src/App.tsx', 'src/screens/AppView.tsx', 'src/screens/Ignored.tsx', 'src/screens/Portfolio.tsx']) { + const src = readFileSync(join(import.meta.dir, '..', f), 'utf8'); + const lines = src.split('\n').filter(l => l.includes('=> appMenu(')); + expect(lines.length, f).toBeGreaterThan(0); + for (const l of lines) expect(l, f).toMatch(/`app:\$\{/); + } + }); +}); diff --git a/apps/desktop/tests/sections.test.ts b/apps/desktop/tests/sections.test.ts new file mode 100644 index 0000000..e5a6494 --- /dev/null +++ b/apps/desktop/tests/sections.test.ts @@ -0,0 +1,79 @@ +// App sections (docs IA): old tab ids and section links resolve to one place, the sections an app has, the "what's +// next" line of each section and the sidebar's status line. +import { describe, expect, test } from 'bun:test'; +import type { StoreRelease } from '../src/api'; +import type { Attention } from '../src/lib/model'; +import { isGameSummary, sideStatus } from '../src/lib/model'; +import { listingProblems, type SectionInput, sectionNext } from '../src/lib/sections'; +import { APP_TABS, parseRouteJson, parseStart, placeOf, SECTION_TABS, SECTIONS, sectionOf, sectionsFor, subsOf } from '../src/nav'; + +const rel = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase'], o: Partial = {}): StoreRelease => + ({ store, version, phase, fraction: null, track: null, ...o }) as StoreRelease; +const base: SectionInput = { project: true, att: [], all: [], next: null, summary: null, catalog: null, unsaved: 0, listings: null, reviews: null, games: null }; +const att = (tone: Attention['tone'], tab: Attention['tab'], text = 'x'): Attention => ({ app: 'A', projectId: 'a', tone, text, tab }); + +describe('places', () => { + test('every old tab id is in exactly one section', () => { + for (const tab of APP_TABS) expect(SECTIONS.filter(s => SECTION_TABS[s].includes(tab))).toHaveLength(1); + }); + test('old tab ids, sections and section/sub links', () => { + expect(placeOf('prices')).toBe('prices'); + expect(placeOf('revenue')).toBe('catalog'); + expect(placeOf('store/art')).toBe('art'); + expect(placeOf('store/prices')).toBe('listings'); + expect(placeOf('health')).toBe('health'); + expect(placeOf('nope')).toBe('releases'); + expect(parseStart('app:players/games:2', ['a', 'b'])).toEqual({ screen: 'app', appId: 'b', tab: 'games' }); + expect(parseStart('app:release', ['a'])).toEqual({ screen: 'app', appId: 'a', tab: 'releases' }); + expect(parseRouteJson('{"screen":"app","appId":"k","tab":"store/appinfo"}')).toEqual({ screen: 'app', appId: 'k', tab: 'appinfo' }); + expect(sectionOf('testflight')).toBe('release'); + }); + test('store-only apps have no Revenue; Game services only for games with a folder', () => { + expect(sectionsFor(false)).toEqual(['release', 'store', 'players', 'health']); + expect(subsOf('players', true, false)).toEqual(['reviews']); + expect(subsOf('players', true, true)).toEqual(['reviews', 'games']); + expect(subsOf('players', false, true)).toEqual(['reviews']); + expect(subsOf('store', false)).toEqual(['listings', 'art']); + }); + test('a game: profile, games config or a Play game category', () => { + expect(isGameSummary({ profile: 'app' } as never)).toBe(false); + expect(isGameSummary({ profile: 'game' } as never)).toBe(true); + expect(isGameSummary({ profile: 'app', games: { gameCenter: true, playGames: false, leaderboards: 0, achievements: 0 } } as never)).toBe(true); + expect(isGameSummary({ profile: 'app', playRelease: { managedPublishing: null, countries: null, category: 'Puzzle' } } as never)).toBe(true); + }); +}); + +describe('next step per section', () => { + test('release: rejected first, then blockers, rollout, review (managed), upload, live', () => { + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'rejected')], att: [att('bad', 'checks')] })).toMatchObject({ tone: 'bad', tab: 'releases' }); + expect(sectionNext('release', { ...base, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ text: '2 blockers before submit', tab: 'checks' }); + expect(sectionNext('release', { ...base, all: [rel('android', '3.0', 'rollout', { fraction: 0.2 })] }).text).toContain('20'); + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'review', { managed: true })] }).text).toContain('Publishing overview'); + expect(sectionNext('release', base)).toMatchObject({ tab: 'releases', text: 'Upload a first build' }); + expect(sectionNext('release', { ...base, all: [rel('ios', '2.1', 'live')] })).toMatchObject({ tone: 'ok', tab: null }); + }); + test('store, revenue, players, health', () => { + const listings = { files: { play: null, apple: null }, problems: [], locales: [{ lang: 'en', rtl: false, screenshots: null, complete: 0.5, apple: null, play: { code: 'en-US', fields: [{ field: 'title', length: 0, limit: 30, required: true, state: 'missing' }] } }] } as never; + expect(listingProblems(listings)).toBe(1); + expect(sectionNext('store', { ...base, listings })).toMatchObject({ text: '1 listing problem', tab: 'listings' }); + expect(sectionNext('revenue', base).tab).toBe('catalog'); + expect(sectionNext('revenue', { ...base, catalog: { checked: 'live', ok: false, products: 2, subscriptions: 0, onlyIos: ['a'], onlyAndroid: [], missingIos: [], missingAndroid: ['b'] } })).toMatchObject({ tone: 'warn', text: '2 products differ between the stores' }); + expect(sectionNext('players', { ...base, reviews: [{}, { reply: { text: 'hi' } }, {}] })).toMatchObject({ text: '2 reviews without a reply', tab: 'reviews' }); + expect(sectionNext('players', { ...base, reviews: [], games: { changes: 0, blockers: 0, apiOff: true } })).toMatchObject({ tone: 'bad', tab: 'games' }); + expect(sectionNext('players', { ...base, reviews: [] }).tone).toBe('ok'); + expect(sectionNext('health', { ...base, att: [att('bad', 'health', 'Crash rate over the line')] })).toMatchObject({ text: 'Crash rate over the line', tab: 'health' }); + expect(sectionNext('health', { ...base, att: [att('bad', 'checks')] })).toMatchObject({ text: '1 problem to fix first', tab: 'checks' }); + }); +}); + +describe('sidebar status line', () => { + const o = { att: [] as Attention[], all: [] as StoreRelease[], read: true, missing: false, storeOnly: false }; + test('groups and lines', () => { + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'rejected')] })).toEqual({ group: 'attention', text: 'Rejected · iOS 2.1', tone: 'bad' }); + expect(sideStatus({ ...o, att: [att('bad', 'checks'), att('bad', 'checks')] })).toMatchObject({ group: 'attention', text: '2 blockers' }); + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'review'), rel('android', '2.0', 'live')] })).toMatchObject({ group: 'progress', text: 'In review · iOS 2.1' }); + expect(sideStatus({ ...o, all: [rel('ios', '2.1', 'live'), rel('android', '2.1', 'live')] })).toEqual({ group: 'live', text: 'Live · 2.1', tone: 'ok' }); + expect(sideStatus({ ...o, read: false })).toMatchObject({ group: 'draft', text: 'Not read yet' }); + expect(sideStatus({ ...o, missing: true })).toMatchObject({ group: 'attention', text: 'Folder missing' }); + }); +}); diff --git a/apps/desktop/tests/tray-menu.test.ts b/apps/desktop/tests/tray-menu.test.ts new file mode 100644 index 0000000..1b36a6f --- /dev/null +++ b/apps/desktop/tests/tray-menu.test.ts @@ -0,0 +1,85 @@ +// The tray menu panels' page (src/traymenu/): the tree from Rust, paths by stable keys, the row split, filtering, +// keyboard moves, a live update that keeps the highlight, and the page's wiring (its own bundle, dev demo only in dev). +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { filterNodes, findPath, isTearOff, keepActive, type MenuNode, nextIndex, rows, split } from '../src/traymenu/model'; + +const ROOT = join(import.meta.dir, '..'); +const src = (f: string) => readFileSync(join(ROOT, f), 'utf8'); +const item = (id: string, label: string, enabled = true): MenuNode => ({ kind: 'item', id, label, enabled }); +const tree = (state: string): MenuNode[] => [ + item('tray-open', 'Open storebound'), + { kind: 'sep' }, + { kind: 'sub', key: 'apps', label: 'Apps', items: [item('tray-app:k1', `First · ${state}`), item('tray-app:k2', 'Second')] }, + item('tray-none', 'No apps yet', false), + item('tray-quit', 'Quit storebound'), +]; + +describe('tray menu model', () => { + test('a row splits into its text and the live value after the middle dot', () => { + expect(split('First · App Store: In review · Play: Live')).toEqual({ text: 'First', value: 'App Store: In review · Play: Live' }); + expect(split('Settings')).toEqual({ text: 'Settings' }); + expect(split('Odd · ')).toEqual({ text: 'Odd' }); + }); + test('paths follow submenu keys; a gone submenu gives null', () => { + expect(findPath(tree('x'), [])!.label).toBeNull(); + expect(findPath(tree('x'), ['apps'])!.items).toHaveLength(2); + expect(findPath(tree('x'), ['apps'])!.label).toBe('Apps'); + expect(findPath(tree('x'), ['gone'])).toBeNull(); + }); + test('typing filters rows by every word, separators drop out', () => { + const r = filterNodes(tree('x'), 'stor quit'); + expect(r.map(n => (n.kind === 'sep' ? '-' : n.label))).toEqual(['Quit storebound']); + expect(filterNodes(tree('x'), ' ')).toHaveLength(5); + }); + test('keyboard moves skip disabled rows and wrap', () => { + const en = rows(tree('x')).map(n => n.kind === 'sub' || n.enabled); + expect(en).toEqual([true, true, false, true]); + expect(nextIndex(1, 1, en)).toBe(3); + expect(nextIndex(3, 1, en)).toBe(0); + expect(nextIndex(-1, 1, en)).toBe(0); + expect(nextIndex(-1, -1, en)).toBe(3); + expect(nextIndex(0, 1, [])).toBe(-1); + }); + test('a live update keeps the highlight on the same row', () => { + const before = rows(findPath(tree('In review'), ['apps'])!.items); + const after = rows(findPath([...tree('Live')], ['apps'])!.items).slice().reverse(); + expect(keepActive(before, after, 0)).toBe(1); // First moved to index 1 + expect(keepActive(before, after.slice(0, 1), 0)).toBe(0); // gone: the same place, clamped + expect(keepActive(before, after, -1)).toBe(-1); + expect(keepActive(before, [], 0)).toBe(-1); + }); + test('Ctrl / Cmd + click or a middle click tears a submenu off', () => { + expect(isTearOff({ button: 1, ctrlKey: false, metaKey: false })).toBe(true); + expect(isTearOff({ button: 0, ctrlKey: true, metaKey: false })).toBe(true); + expect(isTearOff({ button: 0, ctrlKey: false, metaKey: true })).toBe(true); + expect(isTearOff({ button: 0, ctrlKey: false, metaKey: false })).toBe(false); + }); +}); + +describe('tray menu page', () => { + test('its own page in the build, with the demo only behind import.meta.env.DEV', () => { + expect(src('vite.config.ts')).toMatch(/menu: 'tray-menu\.html'/); + expect(src('tray-menu.html')).toMatch(/src="\/src\/traymenu\/main\.tsx"/); + const main = src('src/traymenu/main.tsx'); + expect(main).toMatch(/!inTauri\(\) && import\.meta\.env\.DEV/); + expect(main).toMatch(/await import\('\.\/demo'\)/); + expect(main).not.toMatch(/^import .*\.\/demo/m); + }); + test('the page never polls: values come from menu-model pushes', () => { + const main = src('src/traymenu/main.tsx'); + expect(src('src/traymenu/menu-api.ts')).toMatch(/sub\('menu-model'/); + expect(main).toMatch(/onModel\(setNodes\)/); + expect(main).not.toMatch(/setInterval/); + expect(src('src/traymenu/TrayMenu.tsx')).not.toMatch(/setInterval|requestAnimationFrame/); + }); + test('every command the page calls is a menu command Rust grants to menu-* windows', () => { + const api = src('src/traymenu/menu-api.ts'); + const called = [...api.matchAll(/call\('(\w+)'/g), ...api.matchAll(/invoke<[^>]*>\('(\w+)'/g)].map(m => m[1]); + const list = src('src-tauri/src/commands_list.rs'); + const menu = list.slice(list.indexOf('MENU_COMMANDS')); + expect(called.length).toBeGreaterThan(5); + for (const c of called) expect(menu, c).toContain(`"${c}"`); + }); +}); diff --git a/apps/desktop/tray-menu.html b/apps/desktop/tray-menu.html new file mode 100644 index 0000000..b765ee1 --- /dev/null +++ b/apps/desktop/tray-menu.html @@ -0,0 +1,14 @@ + + + + + + + + storebound + + + + + + diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index ba701d6..84580da 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -16,7 +16,8 @@ export default defineConfig({ // no inline scripts or styles: the CSP is script-src 'self', style-src 'self' assetsInlineLimit: 0, cssCodeSplit: false, - // two pages: the main window and the tray card (pre-created hidden by src-tauri/src/card.rs) - rollupOptions: { input: { main: 'index.html', card: 'card.html' } }, + // three pages: the main window, the tray card (pre-created hidden by src-tauri/src/card.rs) and the tray menu panels + // (one window per panel, src-tauri/src/menu_panel.rs) + rollupOptions: { input: { main: 'index.html', card: 'card.html', menu: 'tray-menu.html' } }, }, }); diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..8f47990 --- /dev/null +++ b/bun.lock @@ -0,0 +1,830 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "storebound", + "devDependencies": { + "@mermaid-js/mermaid-cli": "12.0.0", + "@phosphor-icons/core": "2.1.1", + "@types/bun": "1.3.0", + "playwright": "1.55.0", + "playwright-core": "1.55.0", + "simple-icons": "16.34.0", + "typescript": "5.9.3", + }, + "peerDependencies": { + "playwright": ">=1.45", + }, + "optionalPeers": [ + "playwright", + ], + }, + "apps/desktop": { + "name": "@storebound/desktop", + "version": "0.8.69", + "dependencies": { + "@phosphor-icons/react": "^2.1.10", + "@tauri-apps/api": "^2.12.0", + "@visx/axis": "^4.0.0", + "@visx/curve": "^4.0.0", + "@visx/event": "^4.0.0", + "@visx/gradient": "^4.0.0", + "@visx/group": "^4.0.0", + "@visx/responsive": "^4.0.0", + "@visx/scale": "^4.0.0", + "@visx/shape": "^4.0.0", + "@visx/tooltip": "^4.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + }, + "devDependencies": { + "@tauri-apps/cli": "^2.12.1", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^4.7.0", + "typescript": "5.9.3", + "vite": "^6.3.0", + }, + }, + }, + "packages": { + "@antfu/install-pkg": ["@antfu/install-pkg@2.1.0", "", { "dependencies": { "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.1" } }, "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.11", "", {}, "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.10", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="], + + "@babel/parser": ["@babel/parser@7.29.11", "", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": "./bin/babel-parser.js" }, "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.10", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="], + + "@babel/types": ["@babel/types@7.29.11", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="], + + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@13.2.0", "", { "dependencies": { "@chevrotain/gast": "13.2.0", "@chevrotain/types": "13.2.0" } }, "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg=="], + + "@chevrotain/gast": ["@chevrotain/gast@13.2.0", "", { "dependencies": { "@chevrotain/types": "13.2.0" } }, "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg=="], + + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@13.2.0", "", {}, "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw=="], + + "@chevrotain/types": ["@chevrotain/types@13.2.0", "", {}, "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg=="], + + "@chevrotain/utils": ["@chevrotain/utils@13.2.0", "", {}, "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react": ["@floating-ui/react@0.27.20", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@fontsource-variable/recursive": ["@fontsource-variable/recursive@5.3.0", "", {}, "sha512-4B4/lWCVeZgdbpJdIIU1sOVO/ECjjM673ohuxvyQ6uCK41TPlGzKRNokKY84noUgGYnpwc//MW4k2KrOMY19jQ=="], + + "@fontsource/open-sans": ["@fontsource/open-sans@5.3.0", "", {}, "sha512-V1bdMlGNyZrJwzrdusshMOw2YR0aqYDL7kOo9dkxUEKl8dFv66A9HLckOOXl572sG/7ohhDQrCrTFGSn9LZcSw=="], + + "@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@7.3.1", "", {}, "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg=="], + + "@headlessui/react": ["@headlessui/react@2.2.10", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.20.2", "@react-aria/interactions": "^3.25.0", "@tanstack/react-virtual": "^3.13.9", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA=="], + + "@headlessui/tailwindcss": ["@headlessui/tailwindcss@0.2.2", "", { "peerDependencies": { "tailwindcss": "^3.0 || ^4.0" } }, "sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.7", "", { "dependencies": { "@antfu/install-pkg": "^2.0.1", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w=="], + + "@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="], + + "@internationalized/number": ["@internationalized/number@3.6.9", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-GgWy2caO/+hV9sT/oCTWGHvbjmJMPT9kkXzoKRLnkEq7adghhR7UprhvY9sXrKfrp3NcDfhF6QQHXR1KfQbB8g=="], + + "@internationalized/string": ["@internationalized/string@3.2.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@mermaid-js/layout-tidy-tree": ["@mermaid-js/layout-tidy-tree@1.0.1", "", { "dependencies": { "d3": "^7.9.0" }, "peerDependencies": { "mermaid": "^11.0.2 || ^12.0.0" } }, "sha512-suZgFgQQHYuAxiEiNVgPJwOoXi/keXV6DghCL3rT4K9DmX7xYQ2XZI2suiBVs/fj1gn/OoXIWB2DFSmeY7trvw=="], + + "@mermaid-js/mermaid-cli": ["@mermaid-js/mermaid-cli@12.0.0", "", { "dependencies": { "@fontsource-variable/recursive": "^5.3.0", "@fontsource/open-sans": "^5.3.0", "@fortawesome/fontawesome-free": "^7.0.1", "@mermaid-js/mermaid-zenuml": "^1.0.1", "chalk": "^6.0.0", "commander": "^15.0.0", "import-meta-resolve": "^4.1.0", "katex": "^0.16.25", "mermaid": "^12.0.0", "p-limit": "^7.0.0" }, "optionalDependencies": { "@mermaid-js/layout-tidy-tree": "^1.0.1" }, "peerDependencies": { "puppeteer": "^25.0.0" }, "bin": { "mmdc": "src/cli.js" } }, "sha512-AA18uaJtWVlM5PX7RGBxrVrqTTesmEaWCmfV+l5RkD6A1CnpUAtPpL+wWhkQAZrDSfyAw5FZyj084MsnDIPY3g=="], + + "@mermaid-js/mermaid-zenuml": ["@mermaid-js/mermaid-zenuml@1.0.1", "", { "dependencies": { "@zenuml/core": "^3.47.9" }, "peerDependencies": { "mermaid": "^10 || ^11 || ^12" } }, "sha512-O5DiRODoFVNgugOVloxcDXmd+bXe/sbGlDh3onnzuE4EWtgTWwaAlWuPPOXbU9QESsztnD4Gtnu7dr+tYLFE0A=="], + + "@mermaid-js/parser": ["@mermaid-js/parser@2.0.1", "", { "dependencies": { "@chevrotain/types": "~13.2.0" } }, "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA=="], + + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@phosphor-icons/core": ["@phosphor-icons/core@2.1.1", "", {}, "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ=="], + + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@3.2.6", "", { "dependencies": { "modern-tar": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug=="], + + "@react-aria/focus": ["@react-aria/focus@3.22.1", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-aria": "^3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-CPxtkyrBi/HYY5P3lE/57sQ6qfa0lN8E55TOm89H0kNGv0lKt+/0zP7lWERzBjRr5IxBVrQX4gFEowBN52LPaA=="], + + "@react-aria/interactions": ["@react-aria/interactions@3.28.1", "", { "dependencies": { "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", "react-aria": "^3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Bqb+HrD5I5MHS2SKBhISYqo2SW8Y2dfzgF/Y1lIJq7xqLxheo9vzxPGEHhz+XzkgGfoqEJx8A6a3C7uiqS3HWA=="], + + "@react-types/shared": ["@react-types/shared@3.37.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-VNlZuifu4tHdxWPfHvoMfKsHA9kVTCGkduv5vKVTdinm8wHFJNhyVB5RzIMiRFj0q3RKoCbP68u2zaoBSiFueg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.66.0", "", { "os": "android", "cpu": "arm" }, "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.66.0", "", { "os": "android", "cpu": "arm64" }, "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.66.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.66.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.66.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.66.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.66.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.66.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.66.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.66.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.66.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.66.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.66.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.66.0", "", { "os": "win32", "cpu": "x64" }, "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.66.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg=="], + + "@storebound/desktop": ["@storebound/desktop@workspace:apps/desktop"], + + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.23", "", { "dependencies": { "@tanstack/virtual-core": "3.19.8" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Oyy58qQwdaQieA+A4WUDtnh9D1rv5FZvGdw8og9Xeyh88V6HH94vMSyV5i9ATIbYNe9t5Ix0WXRo9NOTpHDkYw=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.19.8", "", {}, "sha512-Oz/SYBo/aVPyQkwtOw7Q23myITUgFIof2glQqydYJAPL6mH2SkJDkvDKZTklB+7gv5jGLZQhyTcd7SO1A1EODQ=="], + + "@tauri-apps/api": ["@tauri-apps/api@2.12.4", "", {}, "sha512-pVmfis+4zT8H0Qhp3xPZx9Ju7kpzvA6rr/PSJPyuwfvucTQ8sVt61NYU9KMkK703UFlYadFmjKA4HhybOtN0gg=="], + + "@tauri-apps/cli": ["@tauri-apps/cli@2.12.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.12.4", "@tauri-apps/cli-darwin-x64": "2.12.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.4", "@tauri-apps/cli-linux-arm64-gnu": "2.12.4", "@tauri-apps/cli-linux-arm64-musl": "2.12.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.4", "@tauri-apps/cli-linux-x64-gnu": "2.12.4", "@tauri-apps/cli-linux-x64-musl": "2.12.4", "@tauri-apps/cli-win32-arm64-msvc": "2.12.4", "@tauri-apps/cli-win32-ia32-msvc": "2.12.4", "@tauri-apps/cli-win32-x64-msvc": "2.12.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-JI9bHfudSRJikGOrjbXEm6RjzzvmKuLc+JixB1NE8hNzQkNlCCIC3pTES3uypkR9lxHrsbxzcnSwhI2IxBQphw=="], + + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.12.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OxqKSckIkoJ6vX2AjifIJpTasFC0Hbdoo7gEiCmAjauABO3U8m2MRsLWe+QTiBwDx1mIrvgA+rvI7tvDnbnHVg=="], + + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.12.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fz76/77wMMJOaaUN6n3CybRG0P/MTEjjqYk81mGPyhi9m6Ue5T+LWBnJP/AkPooNatBPI2vtulGqomyMJ44lCQ=="], + + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.12.4", "", { "os": "linux", "cpu": "arm" }, "sha512-K0w8Bof6Lt8QKLAYhRy0yy36ZH5iA/35VuSOvnxyjvCUZ+A3JKsUTlALviH/sVgAlisaPzzmeJSH8XOn4Q2HBA=="], + + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.12.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-j5rOnjmRUwWrm1oosf0zBYl4D+fqydgUROFCk0hvKZxvbTT8SOERes0QB7rBV/XWNy+kM6pCKzb3oSm2sRglzg=="], + + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.12.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-aOue3Fy6Jb7+oWcfAVUsVHfeTybSCt2CZoU06aO4jv4d2Ks/KnaTPwMAK3CLEduGOvBLY1lbBBrIyomqLLH5Sg=="], + + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.12.4", "", { "os": "linux", "cpu": "none" }, "sha512-Fx6GShY/RvST+t6LMlTHAwd66+1tEaAAGrnU7cysVfaiRP1BKng5Vl6rVmO6rSLOA1Sp1dRzNYM4f0OWQGb7qA=="], + + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.12.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tNzZSa79Z1nRpHxEpQ38lsAFpKlFlYvoXzjTV+lRRn/zSf3wrdUC3wnLcq2XEadchPu4ttK0zivhYzr+XGD19Q=="], + + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.12.4", "", { "os": "linux", "cpu": "x64" }, "sha512-jmZ1pr0xEqUhuEusWtt9Yt+GF6lCnGHUmIL+KIknpbjK/XQ2qkhDBhaxK/JMHM2A8dAacVXCxXJk2EXpwhQUfA=="], + + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.12.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-fGo8pgEHZxdSYgZ/J98m1usJ7NHKmKbq7k6uT0x1rpOq6Bg2YVzzpaAblWk710f5LHYBJIL5QIFS5IrD+4gmTg=="], + + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.12.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-ezl8sQIcjYsye3TUnTH5VcWmWzccZASlQm7hlZt0dYyj1XD0qpVW47u2xYVrIKui0F8uQv/7QGZixdWiClBsbg=="], + + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.12.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DJhDeCqjLDNMWYFy7ZS8cfeAHX2RK0RbQMLiA3bMVVkXRDJ4NBYiHriGqsUhsr9iG41aTb+dJGkFPmPw1gWwVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], + + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.0.3", "", {}, "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.0", "", {}, "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.1", "", {}, "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.1", "", {}, "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.1", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.4", "", {}, "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.2", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.12", "", {}, "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.0", "", {}, "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="], + + "@types/d3-time-format": ["@types/d3-time-format@2.1.0", "", {}, "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.9", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + + "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + + "@visx/axis": ["@visx/axis@4.0.0", "", { "dependencies": { "@visx/group": "4.0.0", "@visx/point": "4.0.0", "@visx/scale": "4.0.0", "@visx/shape": "4.0.0", "@visx/text": "4.0.0", "classnames": "^2.3.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-cSPNO9Aic2UX49AmIeJJ9TQrrAkvUHpHOGqimUAXhbg07VI3HoHDKcXZdrjvQz+g0LhubrUkjj8Gwju2WZ3dtg=="], + + "@visx/bounds": ["@visx/bounds@4.0.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wAfN5fg2bxg/5f3MlKWzGDTKU2hdKkxq8bVWXpPZQV0UiVc0myuU1qa34c7tN3hg5SDe6MJi/bae2eTQDgEiQ=="], + + "@visx/curve": ["@visx/curve@4.0.0", "", { "dependencies": { "@visx/vendor": "4.0.0" } }, "sha512-bXrOSd2BzVCxR7kBE7gPTSU4plxzYN75w7erdHYabwn3vzP+xFk1o0gg3NOHEPJzt5tXqf0Ecm5YqHACOnipTA=="], + + "@visx/event": ["@visx/event@4.0.0", "", { "dependencies": { "@visx/point": "4.0.0" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-sOcVVzOUxOefF8mQenRFtutoEO7P9icpTcSJM36Sv13sepQbPBuYLknX7RV+EzqyNLOze5cO5GmLIUNH82FOoA=="], + + "@visx/gradient": ["@visx/gradient@4.0.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-AycKA/9Hhx9BXOdWACqYKGU+Dkq/gWzgaDAK/MkVDjXME+3Sl8oE6mn/ZO54gi6wg9qNHhWLnp8XKPJSp4/pzA=="], + + "@visx/group": ["@visx/group@4.0.0", "", { "dependencies": { "classnames": "^2.3.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-HGAq8BCqn5x0t94CJOLJeKtWss9LFpF3HY69HbuO1PlR+B9c4aVT9xbjfudmU8wGQZLT6JNt+PJvxbO2aJ+1aA=="], + + "@visx/point": ["@visx/point@4.0.0", "", {}, "sha512-hwJ9UlIjg3iV4iiJ3RN5SNTXama+zGaNe57sXSrSvPiqhkDXypsYBdDSlAnHmKbMzHP0zFmgB1UJciB/QUyigw=="], + + "@visx/responsive": ["@visx/responsive@4.0.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-MGPK8AeiBIhzz34o9TNJIyUDQIdWEj5mt+U/Je4qVRRXpXSMhor1IE7v71czAS5b90OrFxC9L3w27AV+szG25w=="], + + "@visx/scale": ["@visx/scale@4.0.0", "", { "dependencies": { "@visx/vendor": "4.0.0" } }, "sha512-Q3VaSLKlsrxxSl9CwS2D3Mvz4/4kIp/6NMKRY8Im0Au1hrQh2egU806p0+JwN0ccuGGDe03jyAxX4BSRAH8Plg=="], + + "@visx/shape": ["@visx/shape@4.0.0", "", { "dependencies": { "@visx/curve": "4.0.0", "@visx/group": "4.0.0", "@visx/scale": "4.0.0", "@visx/vendor": "4.0.0", "classnames": "^2.3.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-X0FP3OFjQhc5/6Vj2aY7cK2JwKYI0H0hQM7cdNRWy/ZMMlrh5KpEdqx0HVo0KoTeTlkUvO+kydMLnAuuCRFIFw=="], + + "@visx/text": ["@visx/text@4.0.0", "", { "dependencies": { "classnames": "^2.3.1", "reduce-css-calc": "^1.3.0" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-39f2goSaKy5Mqn89lRDGSJ1IMr49wplkbIFHsFI8cPnAPbguiJxYZ+ulizd7Vcsq2z/hytuGv3Lmk165zUiUYQ=="], + + "@visx/tooltip": ["@visx/tooltip@4.0.0", "", { "dependencies": { "@visx/bounds": "4.0.0", "classnames": "^2.3.1", "react-use-measure": "^2.0.4" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+6J2h5qPvniT0pQtxQrLYGErPRVHr3pRfcMkNqTxD/HgofDGsL6vEBkjMR535MF1hkWYQ9XzOiWP6ERN4TF+mg=="], + + "@visx/vendor": ["@visx/vendor@4.0.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-LoBNzWjTXzBfu095BQxB14IwZQHHOLs8ZMzM6t2FL4ZGORaACgLcmXRRLhFo0VmRR8L7iNTM4HHc1j6yFvzsAA=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@zenuml/core": ["@zenuml/core@3.50.1", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@headlessui/react": "^2.2.9", "@headlessui/tailwindcss": "^0.2.2", "antlr4": "~4.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "color-string": "^2.1.4", "dompurify": "^3.3.1", "highlight.js": "^11.10.0", "html-to-image": "^1.11.13", "jotai": "^2.16.1", "marked": "^4.3.0", "react": "^19.2.3", "react-dom": "^19.2.3", "tailwind-merge": "^3.4.0" }, "optionalDependencies": { "@napi-rs/canvas": "^0.1.97" }, "peerDependencies": { "playwright-core": "1.57.0" }, "optionalPeers": ["playwright-core"], "bin": { "zenuml": "dist/cli/zenuml.mjs" } }, "sha512-Q18BXvIfhRuGw0JfO4e+pltG04Phhv2E7n35EWTNSZKKH/HEkyE4Sh7KERb9dqaOfjOdRdKvLG0YekjK3fcDXw=="], + + "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "antlr4": ["antlr4@4.11.0", "", {}, "sha512-GUGlpE2JUjAN+G8G5vY+nOoeyNhHsXoIJwP1XF1oRw89vifA1K46T6SEkwLwr7drihN7I/lf0DIjKc4OZvBX8w=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "balanced-match": ["balanced-match@0.4.2", "", {}, "sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA=="], + + "browserslist": ["browserslist@4.29.5", "", { "dependencies": { "baseline-browser-mapping": "^2.11.33", "caniuse-lite": "^1.0.30001819", "electron-to-chromium": "^1.5.464", "node-releases": "^2.0.60", "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ=="], + + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001820", "", {}, "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w=="], + + "chalk": ["chalk@6.0.1", "", {}, "sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA=="], + + "chevrotain": ["chevrotain@13.2.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "13.2.0", "@chevrotain/gast": "13.2.0", "@chevrotain/regexp-to-ast": "13.2.0", "@chevrotain/types": "13.2.0", "@chevrotain/utils": "13.2.0" } }, "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA=="], + + "chromium-bidi": ["chromium-bidi@157.0.8090-0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^4.6.5" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="], + + "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "cytoscape": ["cytoscape@3.34.4", "", {}, "sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.2", "", { "dependencies": { "delaunator": "5" } }, "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-geo": ["d3-geo@3.1.0", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + + "devtools-protocol": ["devtools-protocol@0.0.1704951", "", {}, "sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg=="], + + "dompurify": ["dompurify@3.4.17", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.467", "", {}, "sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw=="], + + "elkjs": ["elkjs@0.9.3", "", {}, "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "es-toolkit": ["es-toolkit@1.53.0", "", {}, "sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + + "highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="], + + "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "jotai": ["jotai@2.20.3", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-N8L9FUbeGDP+0qNJw1FebOxt+5hk+jTOwUA2LlRE4C081jUcY6F1T/FSETp4DT2/INMtiSRZyNm2D+yZdzrSgA=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + + "lilconfig": ["lilconfig@3.1.4", "", {}, "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w=="], + + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + + "math-expression-evaluator": ["math-expression-evaluator@1.4.0", "", {}, "sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw=="], + + "mermaid": ["mermaid@12.2.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^2.0.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "chevrotain": "~13.2.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", "dompurify": "^3.4.12", "elkjs": "^0.9.3", "es-toolkit": "^1.45.1", "katex": "^0.18.0", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-w7nr/SFrLO7BaVTs3dQTvYSV6nYSBj0Yy9DjBhsIJtAcHqRzXSq4Pg32ujHZ/AdWsAMNEvvhbm5dt9E2tOiHzw=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "modern-tar": ["modern-tar@0.8.6", "", {}, "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "node-releases": ["node-releases@2.0.60", "", {}, "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ=="], + + "p-limit": ["p-limit@7.3.3", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ=="], + + "package-manager-detector": ["package-manager-detector@1.9.0", "", {}, "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA=="], + + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "playwright": ["playwright@1.55.0", "", { "dependencies": { "playwright-core": "1.55.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA=="], + + "playwright-core": ["playwright-core@1.55.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg=="], + + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + + "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], + + "puppeteer": ["puppeteer@25.16.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.6", "chromium-bidi": "157.0.8090-0", "devtools-protocol": "0.0.1704951", "lilconfig": "^3.1.3", "puppeteer-core": "25.16.0", "typed-query-selector": "^2.12.3" }, "bin": { "puppeteer": "lib/puppeteer/node/cli.js" } }, "sha512-LOr3reYnxAYYfQkpopz1fQnL0LdvqhpR5U2Fu+QnwzTX2gpVTHsn91UETo5NNC1md2/oC54Zu1ChBXgZDNSP1A=="], + + "puppeteer-core": ["puppeteer-core@25.16.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.6", "chromium-bidi": "157.0.8090-0", "devtools-protocol": "0.0.1704951", "typed-query-selector": "^2.12.3", "webdriver-bidi-protocol": "1.0.1", "ws": "^8.22.0" } }, "sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw=="], + + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], + + "react-aria": ["react-aria@3.53.1", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.9", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.51.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-dikisyOjJd3D2fFrIDWmVWzqiLz5B86GzZv1zxs7xI4dVc0JIz/1Wa52I20gYvVL2WXeFXlygYsle/QcOMhtcg=="], + + "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-stately": ["react-stately@3.51.0", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.9", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-YJcmkAXu2XOQP97hO/qz/cC3qv9kni3oVhwBlxWr358BHePJUpY15XwL3YSRMKxYt4EkslPqAz11JRirMfANGw=="], + + "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], + + "reduce-css-calc": ["reduce-css-calc@1.3.0", "", { "dependencies": { "balanced-match": "^0.4.2", "math-expression-evaluator": "^1.2.14", "reduce-function-call": "^1.0.1" } }, "sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA=="], + + "reduce-function-call": ["reduce-function-call@1.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ=="], + + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + + "rollup": ["rollup@4.66.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.66.0", "@rollup/rollup-android-arm64": "4.66.0", "@rollup/rollup-darwin-arm64": "4.66.0", "@rollup/rollup-darwin-x64": "4.66.0", "@rollup/rollup-freebsd-arm64": "4.66.0", "@rollup/rollup-freebsd-x64": "4.66.0", "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", "@rollup/rollup-linux-arm-musleabihf": "4.66.0", "@rollup/rollup-linux-arm64-gnu": "4.66.0", "@rollup/rollup-linux-arm64-musl": "4.66.0", "@rollup/rollup-linux-loong64-gnu": "4.66.0", "@rollup/rollup-linux-loong64-musl": "4.66.0", "@rollup/rollup-linux-ppc64-gnu": "4.66.0", "@rollup/rollup-linux-ppc64-musl": "4.66.0", "@rollup/rollup-linux-riscv64-gnu": "4.66.0", "@rollup/rollup-linux-riscv64-musl": "4.66.0", "@rollup/rollup-linux-s390x-gnu": "4.66.0", "@rollup/rollup-linux-x64-gnu": "4.66.0", "@rollup/rollup-linux-x64-musl": "4.66.0", "@rollup/rollup-openbsd-x64": "4.66.0", "@rollup/rollup-openharmony-arm64": "4.66.0", "@rollup/rollup-win32-arm64-msvc": "4.66.0", "@rollup/rollup-win32-ia32-msvc": "4.66.0", "@rollup/rollup-win32-x64-gnu": "4.66.0", "@rollup/rollup-win32-x64-msvc": "4.66.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A=="], + + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "simple-icons": ["simple-icons@16.34.0", "", {}, "sha512-UTxKn0PVspk84B2hHF3Dqiqht1yDjJmDc/t4cO7PyCQxCMk6OjEu95pnpGjhLaGeQ4qBrGASbefTIe/+czrX7g=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + + "tabbable": ["tabbable@6.5.1", "", {}, "sha512-dMCuwDUY6Noz3zVKLefBC4wq+aJV20yc7vWmM7uTNGwtQSFF5kX7OUIUsxuVajz/bdt5Xqdah3/0NLjth+cM5A=="], + + "tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typed-query-selector": ["typed-query-selector@2.12.3", "", {}, "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="], + + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], + + "uuid": ["uuid@14.0.3", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw=="], + + "vite": ["vite@6.4.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@1.0.1", "", {}, "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yargs": ["yargs@18.2.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + + "@headlessui/react/@floating-ui/react": ["@floating-ui/react@0.26.28", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.8", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw=="], + + "@types/d3/@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3/@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3/@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3/@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3/@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="], + + "@types/d3/@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3/@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3/@types/d3-shape": ["@types/d3-shape@3.2.0", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw=="], + + "@types/d3/@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3/@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-contour/@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-interpolate/@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-scale/@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-zoom/@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@zenuml/core/marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3/d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3/d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3/d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3/d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-contour/d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-geo/d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "d3-scale/d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-scale/d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-time/d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "mermaid/katex": ["katex@0.18.11", "", { "dependencies": { "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA=="], + + "reduce-function-call/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "@types/d3-zoom/@types/d3-interpolate/@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + } +} diff --git a/cli/accounts.ts b/cli/accounts.ts index e95199a..d5b8403 100644 --- a/cli/accounts.ts +++ b/cli/accounts.ts @@ -22,6 +22,7 @@ import { resolve } from 'node:path'; import type { Args } from './lib/args'; import { applyImport, describeProfiles, planImport, setPlayDeveloperId } from './lib/accounts'; import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials'; +import { brokerConfig } from './lib/broker'; import { confirm, type Ctx } from './lib/ctx'; import { textTable } from './lib/table'; import { printJson } from './lib/log'; @@ -42,9 +43,10 @@ export async function run(args: Args): Promise { console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`); return; } - console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'Play developer id', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.playDeveloperId ?? '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : '']))); + console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'Play via', 'Play developer id', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.playSource === 'broker' ? 'credential broker' : r.playSource === 'file' ? 'key file' : '-', r.playDeveloperId ?? '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : '']))); if (pointer && pointer !== def) console.log(`note: the default pointer names "${pointer}", which is not a profile here (storebound accounts default )`); console.log(`\n${tilde(profileDir())}: shared by the CLI and the desktop app`); + try { const b = brokerConfig(); if (b) console.log(`credential broker: ${b.url} (${b.from === 'env' ? 'STOREBOUND_BROKER_URL' : tilde(b.from)}); a profile with "broker": false keeps its key file`); } catch (e) { console.log(`credential broker: not usable: ${(e as Error).message}`); } console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and no default: storebound accounts default , or accounts use in the project)'); return; } diff --git a/cli/analytics.ts b/cli/analytics.ts index f6f3f83..9ca6b63 100644 --- a/cli/analytics.ts +++ b/cli/analytics.ts @@ -1,23 +1,27 @@ // Store analytics snapshots next to the listing changes. // storebound analytics setup [--dry] App Store: create the ONGOING analytics report request (Admin key; once) -// storebound analytics snapshot [--days 28] [--store apple|play] [--dry] -> .storebound/analytics/.json +// storebound analytics snapshot [--days 28] [--store apple|play] [--dry] [--json] [--no-write] -> .storebound/analytics/.json +// --json prints the snapshot as one JSON document on stdout (the file is still written unless --no-write or --dry) // storebound analytics compare [--days 7] [--paths store,storebound.config.ts] [--md] // App Store: Analytics Reports API (Discovery and Engagement: impressions, product page views; Downloads: first-time // downloads and redownloads), every segment checked against its md5 checksum. Google Play has no statistics API: // the monthly CSV exports in your Cloud Storage bucket (play.reportsBucket = "pubsite_prod_rev_", Play Console > // Download reports > Statistics > Copy Cloud Storage URI) are read with the service account (store listing visitors, -// acquisitions, daily user installs). compare lines the snapshots up with the git history of the store files (listing +// acquisitions, daily user installs, active device installs, total user installs). App Usage (App Store: sessions, active +// devices, crashes per day from the App Sessions and App Crashes reports). A store-only config needs no play.reportsBucket +// (env PLAY_REPORTS_BUCKET or the profile's reportsBucket) and no apple.bundleId (apple.appId is enough). compare lines the snapshots up with the git history of the store files (listing // text, screenshots, config): the days before vs after each change. Read-only on both stores except `setup`. import { consoleLink } from './lib/console-links'; import { storeReadError } from './lib/google-errors'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createOnce } from './lib/upsert'; +import { playReportsBucket } from './lib/credentials'; import { ascClient, type Asc, type J } from './apple/asc'; import type { Ctx } from './lib/ctx'; import { type AppleDay, APPLE_REPORTS, appleDaily, appleTotals, bucketName, checksumOk, compareAroundEvents, decodeText, effectMarkdown, effectTable, inWindow, - latestPerDate, mergeSeries, monthsBetween, snapshotProblem, storeErrors, parseGitLog, parseReport, pickReport, type PlayDay, playDaily, playObjects, playTotals, type Snapshot, + latestPerDate, mergeAppleUsage, mergeSeries, monthsBetween, snapshotProblem, storeErrors, parseGitLog, parseReport, pickReport, type PlayDay, playDaily, playObjects, playTotals, type Snapshot, } from './lib/analytics'; import { STORAGE_READ_SCOPE } from './lib/google-auth'; import { done, log, printJson, warn } from './lib/log'; @@ -55,9 +59,9 @@ async function reportRows(c: Asc, report: J, start: string, tag: string): Promis return latestPerDate(parts); } -async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: AppleDay[] }> { +async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: AppleDay[]; notes: string[] }> { const { config, root } = ctx.loaded; - const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId); + const c = await ascClient(root, config.apple!.bundleId ?? '', config.apple?.appId); const appId = await c.app(); const req = await ongoingRequest(c, appId); if (!req) throw new Error('no active ONGOING analytics report request: run storebound analytics setup (Admin key), data starts 1-2 days later'); @@ -65,25 +69,36 @@ async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ da const eng = pickReport(reports.filter(r => r.attributes?.category === APPLE_REPORTS.engagement.category), APPLE_REPORTS.engagement.match); const dl = pickReport(reports.filter(r => r.attributes?.category === APPLE_REPORTS.downloads.category), APPLE_REPORTS.downloads.match); if (!eng || !dl) throw new Error(`reports not found yet (${reports.length} listed): wait a day after setup`); - const daily = appleDaily(await reportRows(c, eng, start, eng.attributes.name), await reportRows(c, dl, start, dl.attributes.name)); - return { daily: inWindow(daily, start, end) }; + let daily = appleDaily(await reportRows(c, eng, start, eng.attributes.name), await reportRows(c, dl, start, dl.attributes.name)); + // App Usage is optional: a team whose request predates the usage reports, or whose usage reports are not out yet, still gets the rest + const notes: string[] = []; + const usage = (m: { category: string; match: RegExp }) => pickReport(reports.filter(r => r.attributes?.category === m.category), m.match); + const ses = usage(APPLE_REPORTS.sessions), cr = usage(APPLE_REPORTS.crashes); + if (ses || cr) { + try { + daily = mergeAppleUsage(daily, ses ? await reportRows(c, ses, start, ses.attributes.name) : [], cr ? await reportRows(c, cr, start, cr.attributes.name) : []); + if (!ses || !cr) notes.push(`App Usage: the ${ses ? 'App Crashes' : 'App Sessions'} report is not listed yet`); + } catch (e) { notes.push(`App Usage not read: ${storeReadError((e as Error).message).error.slice(0, 200)}`); } + } else notes.push('App Usage: the App Sessions / App Crashes reports are not listed yet (they appear a day or two after the first report)'); + for (const n of notes) warn(TAG, `apple: ${n}`); + return { daily: inWindow(daily, start, end), notes }; } async function playSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: PlayDay[] }> { const { config, root } = ctx.loaded; - const bucket = config.play?.reportsBucket; - if (!bucket) throw new Error(`play.reportsBucket is not set (${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`); + const bucket = playReportsBucket(root, config.play?.reportsBucket); + if (!bucket) throw new Error(`play.reportsBucket is not set (env PLAY_REPORTS_BUCKET or the credential profile's reportsBucket also work; ${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`); const g = googleClient(root, STORAGE_READ_SCOPE); - const sp: Record[] = [], inst: Record[] = []; + const sp: Record[] = [], inst: Record[] = [], ov: Record[] = []; for (const month of monthsBetween(start, end)) { const o = playObjects(config.play!.packageName, month); - for (const [name, into] of [[o.storePerformance, sp], [o.installs, inst]] as const) { + for (const [name, into] of [[o.storePerformance, sp], [o.installs, inst], [o.installsOverview, ov]] as const) { const url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(name)}?alt=media`; try { into.push(...parseReport(decodeText(await g.bytes(url)))); log(TAG, `play: ${name}`); } catch (e) { if (/-> 404/.test(String(e))) warn(TAG, `play: ${name} not there (yet)`); else throw e; } } } - return { daily: inWindow(playDaily(sp, inst), start, end) }; + return { daily: inWindow(playDaily(sp, inst, ov), start, end) }; } function readSnapshots(dir: string): Snapshot[] { @@ -100,7 +115,7 @@ export async function run(ctx: Ctx): Promise { if (sub === 'setup') { if (!config.apple) throw new Error('no apple section in the config (Google Play needs no setup: set play.reportsBucket)'); - const c = await ascClient(root, config.apple.bundleId, config.apple.appId); + const c = await ascClient(root, config.apple.bundleId ?? '', config.apple.appId); const appId = await c.app(); const cur = await ongoingRequest(c, appId); if (cur) { done(TAG, `apple: ONGOING report request exists (${cur.id})`); return; } @@ -121,20 +136,24 @@ export async function run(ctx: Ctx): Promise { const end = day(new Date(now.getTime() - 86_400_000)), start = day(new Date(now.getTime() - days * 86_400_000)); const snap: Snapshot = { date: day(now), window: { start, end, days } }; if ((!store || store === 'apple') && config.apple) { - try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily) }; } + try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily), ...(r.notes.length ? { notes: r.notes } : {}) }; } catch (e) { snap.apple = { error: storeReadError((e as Error).message).error.slice(0, 400) }; warn(TAG, `apple: ${snap.apple.error}`); } } if ((!store || store === 'play') && config.play) { try { const r = await playSnapshot(ctx, start, end); snap.play = { daily: r.daily, totals: playTotals(r.daily) }; } catch (e) { snap.play = { error: storeReadError((e as Error).message, { app: config.play.packageName }).error.slice(0, 400) }; warn(TAG, `play: ${snap.play.error}`); } } + const json = a.has('json'); const rows: (string | number)[][] = []; if (snap.apple && !('error' in snap.apple)) { const t = snap.apple.totals; rows.push(['apple', `${t.impressions} impressions`, `${t.pageViews} page views`, `${t.downloads} downloads`, pct(t.conversion ?? null)]); } if (snap.play && !('error' in snap.play)) { const t = snap.play.totals; rows.push(['play', `${t.visitors} visitors`, `${t.acquisitions} acquisitions`, `${t.installs} installs`, pct(t.conversion ?? null)]); } if (rows.length) console.log(textTable(['store', 'reach', 'interest', 'installs', 'conversion'], rows)); const file = join(dir, `${snap.date}.json`); const problem = snapshotProblem(snap); + // --json: the collector gets the document (with each store's error) on stdout even when nothing could be read; the exit code still says it failed + if (json) printJson(snap); if (problem) throw new Error(`no store data, nothing written (${problem})`); + if (a.has('no-write')) { done(TAG, `snapshot ${start} to ${end} (not written: --no-write)`); return; } if (dry) { done(TAG, `dry run: would write ${file} (${start} to ${end})`); return; } mkdirSync(dir, { recursive: true }); writeFileSync(file, JSON.stringify(snap, null, 2) + '\n'); diff --git a/cli/api.ts b/cli/api.ts index a629aed..953919f 100644 --- a/cli/api.ts +++ b/cli/api.ts @@ -35,7 +35,9 @@ export const USAGE = `usage: GETs run right away. Writes print the exact request and are sent only with --yes in a terminal; owner-only operations (submit, release, delete, cancel, prices, testers, review replies, experiments) also ask you to type the operation id. Agents without a terminal: --yes --confirm (must equal the operation id; plain writes -only, never owner-only operations). Full table: docs/api-coverage.md`; +only, never owner-only operations), e.g. + storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance +Full table: docs/api-coverage.md`; export interface Row { store: Store; id: string; method: string; path: string; gate: GateKind; wrapped: string } diff --git a/cli/api/client.ts b/cli/api/client.ts index 17da151..b5d9584 100644 --- a/cli/api/client.ts +++ b/cli/api/client.ts @@ -354,13 +354,12 @@ export async function authFor(store: Store, root: string): Promise { const { jwt } = ascJwt(root); return () => 'Bearer ' + jwt(); } - const { playServiceAccount } = await import('../lib/credentials'); - const { tokenSource } = await import('../lib/google-auth'); - const sa = playServiceAccount(root); - const sources = new Map Promise>(); + const { PLAY_SCOPE, playToken } = await import('../lib/google-auth'); + // the credential broker when it has the scope, else the local key; throws now when neither is there + const sources = new Map Promise>([[PLAY_SCOPE, playToken(root, PLAY_SCOPE)]]); return async req => { const scope = PLAY_APIS[req.op.api as keyof typeof PLAY_APIS].scope; - if (!sources.has(scope)) sources.set(scope, tokenSource(sa, scope)); + if (!sources.has(scope)) sources.set(scope, playToken(root, scope)); return 'Bearer ' + (await sources.get(scope)!()); }; } diff --git a/cli/api/gate.ts b/cli/api/gate.ts index d3f6ef7..c7c8cef 100644 --- a/cli/api/gate.ts +++ b/cli/api/gate.ts @@ -94,7 +94,7 @@ export type Decision = | { action: 'refuse'; why: string }; /** --yes without a terminal: refused loudly (exit 1, stderr), never a silent dry run (pitfall 77). */ -export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm (agents, plain writes only), or use the dedicated storebound command.'; +export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm (agents, plain writes only: --yes --confirm bundleIds_createInstance for the operation bundleIds_createInstance), or use the dedicated storebound command.'; /** --confirm never stands in for the owner's typed id on an owner-only operation. */ export const OWNER_NO_CONFIRM = 'refused: owner-only operation: --confirm does not apply; the owner types the operation id in an interactive terminal. Nothing was sent.'; @@ -107,7 +107,7 @@ export function decide(g: GateInput): Decision { if (g.agent && !g.bypass) return { action: 'refuse', why: g.kind === 'owner' ? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal' : 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' }; - if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' }; + if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : g.kind === 'owner' ? 'writes are dry by default: add --yes (in a terminal; owner-only operations also ask for the typed operation id)' : `writes are dry by default: add --yes in a terminal, or from an agent's shell --yes --confirm (e.g. \`storebound api apple ${g.opId ?? 'bundleIds_createInstance'} --body body.json --yes --confirm ${g.opId ?? 'bundleIds_createInstance'}\`)` }; if (g.confirm !== undefined) { // an agent's typed confirmation: only for plain writes, only when it names this exact operation if (g.kind === 'owner' && !g.bypass) return { action: 'refuse', why: OWNER_NO_CONFIRM }; diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index d361d9a..930482c 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -7,10 +7,11 @@ export const WRAPPED: { apple: Record; play: Record; play: Record; play: Record; play: Record; play: Record; play: Record; play: Record `storebound ${sum}`; +export const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1]; + +/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */ +export const ASSET_STATES = { + /** still uploading / processing: wait (bounded), then select */ + processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'], + /** processed and usable in a slot; not reviewed yet: goes into a review submission */ + unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'], + /** in a submission or reviewed: usable in a slot, nothing to add */ + reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'], +} as const; +export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown'; + +/** What a library asset's state means for storebound. Pure. */ +export function classify(state: string | undefined): AssetClass { + const s = String(state ?? ''); + for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass; + if (s === 'FAILED') return 'failed'; + if (s === 'REJECTED') return 'rejected'; + if (s === 'ARCHIVED') return 'archived'; + return s ? 'unknown' : 'processing'; +} + +/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */ +export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing'; + +/** Stop re-uploading a file whose earlier uploads failed processing this many times. */ +export const MAX_FAILED = 2; + +/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing), + * else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */ +export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } { + const mine = assets.filter(x => sumOf(x) === sum); + const rank: Record = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 }; + const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c); + const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]); + if (live[0]) return { asset: live[0] }; + if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true }; + const rej = by('rejected')[0]; + if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` }; + if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` }; + return {}; +} + +export const reason = (a: J): string => { + const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean); + return d.length ? ` (${d.join(', ')})` : ''; +}; + +/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */ +export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) }; + diff --git a/cli/apple/build-mas.ts b/cli/apple/build-mas.ts index 82beef3..6c16e59 100644 --- a/cli/apple/build-mas.ts +++ b/cli/apple/build-mas.ts @@ -1,7 +1,7 @@ // Mac App Store build of a Tauri app: `tauri build --bundles app`, signed for the Mac App Store, then productbuild to a .pkg. // storebound apple build --platform macos --tauri [--path-free] [--target universal-apple-darwin] -// [--version x.y.z] [--build-number N] [--profile ] [--out build/macos] [--unsigned] [--dry] [--json] -// Steps: Tauri project -> version / build number (next free one on App Store Connect, platform MAC_OS) -> signing +// [--entitlements ] [--allow-entitlements] [--version x.y.z] [--build-number N] [--profile ] [--out build/macos] [--unsigned] [--dry] [--json] +// Steps: Tauri project -> version / build number (next free one on App Store Connect, every platform) -> signing // identities from the keychain ("Apple Distribution" or "3rd Party Mac Developer Application"; "Mac Installer // Distribution" or "3rd Party Mac Developer Installer") -> Mac App Store provisioning profile (--profile, installed, or // from the API) -> entitlements (app sandbox + application / team identifier, the project's own keys kept) -> a @@ -19,11 +19,15 @@ import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { parseMobileprovision, parsePlist, type PlistValue } from '../lib/plist'; -import { nextBuildNumber } from '../lib/release'; +import { appNextBuild } from './next-build'; import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing'; +import { sshBuildRefusal } from '../lib/keychain'; import { remoteBuildSession, showCmd } from '../lib/xcode'; +import { prebuildFor, prebuildsDone, runPrebuild } from '../lib/web-assets'; +import { preCheckCommand } from '../lib/pre-check'; +import { pump } from '../lib/runner'; import { - appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities, + appProblems, entitlementKeysOf, entitlementsSource, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlementProblems, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities, pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId, } from '../lib/mas'; import { readdirSync } from 'node:fs'; @@ -40,6 +44,16 @@ const run1 = (argv: string[], timeout = 120_000): { ok: boolean; out: string } = }; /** The Tauri CLI: the project's node_modules/.bin/tauri (walking up), `cargo tauri`, else bunx. */ +/** One sh -c command in root, its output streamed; the exit code and the last lines. */ +async function runCheck(cmd: string, root: string): Promise<{ ok: boolean; code: number; tail: string }> { + const p = rt.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env }); + const tail: string[] = []; + const each = (line: string, kind: 'stdout' | 'stderr'): void => { tail.push(line); if (tail.length > 10) tail.shift(); (kind === 'stderr' ? console.error : console.log)(` ${line}`); }; + await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]); + const code = await p.exited; + return { ok: code === 0, code, tail: tail.join('\n') }; +} + export function tauriCommand(cwd: string, exists: (p: string) => boolean = existsSync, which: (c: string) => string | null = rt.which): string[] { for (let d = cwd, prev = ''; d !== prev; prev = d, d = dirname(d)) { const p = join(d, 'node_modules', '.bin', 'tauri'); if (exists(p)) return [p]; } if (which('cargo-tauri')) return ['cargo', 'tauri']; @@ -71,7 +85,13 @@ async function fetchMasProfile(c: Asc, bundleId: string, dest: string): Promise< return dest; } -export async function run(ctx: Ctx): Promise { +/** The dry-run line about signing identities: on a machine that is not a Mac, say so instead of "none found". */ +export function identityLine(o: { platform: string; appIdentity: string | null; installerIdentity: string | null; profile: string | null }): string { + if (o.platform !== 'darwin') return `this is not a build machine: Mac App Store builds run on a Mac (the signing identities live in its keychain), so none were looked up here; profile: ${o.profile ?? '(none)'}`; + return `app identity: ${o.appIdentity ?? '(none found)'}; installer identity: ${o.installerIdentity ?? '(none found)'}; profile: ${o.profile ?? '(none)'}`; +} + +export async function run(ctx: Ctx, platform: string = process.platform): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); @@ -91,9 +111,10 @@ export async function run(ctx: Ctx): Promise { try { client = await ascClient(root, bundleId, config.apple?.appId); if (!build) { - const builds = await client.all(`/v1/builds?filter[app]=${await client.app()}&filter[preReleaseVersion.platform]=MAC_OS&limit=200&fields[builds]=version`); - build = nextBuildNumber(builds.map(b => String(b.attributes.version))); - log(TAG, `${builds.length} macOS builds on App Store Connect: next build number ${build} (CFBundleVersion must increase)`); + // every platform of the app record, not only macOS: CFBundleVersion must be unique across all its builds + const found = await appNextBuild(client); + build = found.next; + log(TAG, `${found.builds} builds (all platforms) on App Store Connect: next build number ${build} (CFBundleVersion must increase)`); } if (!teamId) teamId = (await findBundleId(client, bundleId))?.attributes?.seedId ?? null; } catch (e) { @@ -106,7 +127,7 @@ export async function run(ctx: Ctx): Promise { const team = teamId ?? ''; // identities and profile (a real signed run needs them; a dry run shows placeholders) - const mac = process.platform === 'darwin'; + const mac = platform === 'darwin'; let appIdentity: string | null = null, installerIdentity: string | null = null; if (!unsigned && mac) { const picked = pickMasIdentities(parseIdentities(run1(['security', 'find-identity', '-v']).out), teamId); @@ -141,27 +162,59 @@ export async function run(ctx: Ctx): Promise { const overlay = tauriOverlay({ appIdentity, entitlementsFile: entFile, profile, version, build: buildNo, unsigned, minOs: tauri.minOs, category }); const input = { tauri, tauriCmd, target, out, configFile, entitlementsFile: entFile, profile, appIdentity, installerIdentity: installerIdentity ?? '', version, build: buildNo, unsigned, pathFree, pkg }; const steps = masSteps(input); - const existingEnt = tauri.entitlements && existsSync(tauri.entitlements) ? (parsePlist(readFileSync(tauri.entitlements)) as Record) : undefined; + // apple.macos.prebuild first (a project computes its build-time env there), apple.macAppCheck on the built .app before + // productbuild (e.g. a project's own check that a value was stamped in). build.prebuild / apple.prebuild belong to the + // iOS / Android project (a Capacitor sync, an OTA gate) and are not run for a Tauri Mac build + const prebuild = config.apple?.macos?.prebuild?.trim() || null; + const mobilePrebuild = prebuildFor(config, 'apple'); + if (!prebuild && mobilePrebuild && !json) log(TAG, `not run for the Mac build: ${mobilePrebuild} (build.prebuild / apple.prebuild are for the iOS project; set apple.macos.prebuild for the Mac)`); + const prebuildSkip = !prebuild ? undefined : a.has('no-prebuild') ? '--no-prebuild' : prebuildsDone().has(prebuild) ? 'already ran in this flow run' : undefined; + const appCheck = config.apple?.macAppCheck?.trim() || null; + // entitlements: --entitlements, apple.macos.entitlements, the merged Tauri config (the first one that is set) + const entFlag = a.get('entitlements'), entCfg = config.apple?.macos?.entitlements; + const entSrc = entitlementsSource({ flag: entFlag ? resolve(entFlag) : undefined, config: entCfg ? resolve(entCfg) : undefined, tauri: tauri.entitlements }); + let existingEnt: Record | undefined; + if (entSrc) { + if (existsSync(entSrc.file)) existingEnt = parsePlist(readFileSync(entSrc.file)) as Record; + else if (entSrc.source !== 'tauri config bundle.macOS.entitlements') throw new Error(`${entSrc.source}: ${entSrc.file} does not exist`); + else warn(TAG, `${entSrc.source}: ${relative(root, entSrc.file)} does not exist, using the sandbox defaults only`); + } const ent = masEntitlements({ teamId: team, bundleId, existing: existingEnt }); - const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity, - profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])), + const entCheck = masEntitlementProblems(ent); + const allowEnt = a.has('allow-entitlements'); + const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), tauriOverlayFile: tauri.overlayPath ? relative(root, tauri.overlayPath) : null, target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity, + profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), entitlementsSource: entSrc ? { source: entSrc.source, file: relative(root, entSrc.file) } : null, + entitlementProblems: entCheck, tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])), + prebuild, ...(prebuildSkip ? { prebuildSkip } : {}), appCheck, steps: [showCmd(steps.tauri, null), showCmd(steps.codesignVerify, null), showCmd(steps.productbuild, null), showCmd(steps.pkgCheck, null)], out: relative(root, out) || '.', pkg: relative(root, pkg) }; if (dry) { if (json) { printJson(plan); return; } log(TAG, `Tauri ${tauri.productName} (${bundleId}) ${version} (${buildNo}) for ${target}, ${unsigned ? 'UNSIGNED' : 'Mac App Store signing'}${pathFree ? ', path-free' : ''}`); - log(TAG, `app identity: ${appIdentity ?? '(none found)'}; installer identity: ${installerIdentity ?? '(none found)'}; profile: ${plan.profile ?? '(none)'}`); - log(TAG, `entitlements: ${plan.entitlements.join(', ')}`); + log(TAG, identityLine({ platform, appIdentity, installerIdentity, profile: plan.profile })); + if (tauri.overlayPath) log(TAG, `Tauri overlay ${plan.tauriOverlayFile}${tauri.overlayAuto ? ' (found next to tauri.conf.json)' : ''}: merged over tauri.conf.json, passed to tauri build --config`); + log(TAG, `entitlements from ${entSrc?.source ?? 'the sandbox defaults'}${entSrc ? ` (${plan.entitlementsSource!.file})` : ''}: ${plan.entitlements.join(', ')}`); + for (const e of entCheck.errors) warn(TAG, `entitlement ${e}${allowEnt ? '' : ' (the real build stops here; --allow-entitlements builds anyway)'}`); + for (const w of entCheck.warnings) warn(TAG, `entitlement ${w}`); if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`); + if (prebuild) log(TAG, prebuildSkip ? `prebuild skipped (${prebuildSkip}): ${prebuild}` : `would run the prebuild: ${prebuild}`); for (const s of plan.steps) log(TAG, `would run: ${s}`); + if (appCheck) log(TAG, `would check the built .app: ${appCheck}`); done(TAG, 'macOS build planned (dry run)'); return; } + if (tauri.overlayPath) log(TAG, `Tauri overlay ${plan.tauriOverlayFile}${tauri.overlayAuto ? ' (found next to tauri.conf.json)' : ''}: merged over tauri.conf.json, passed to tauri build --config`); + log(TAG, `entitlements from ${entSrc?.source ?? 'the sandbox defaults'}${entSrc ? ` (${plan.entitlementsSource!.file})` : ''}: ${plan.entitlements.join(', ')}`); + for (const w of entCheck.warnings) warn(TAG, `entitlement ${w}`); + for (const e of entCheck.errors) if (allowEnt) warn(TAG, `entitlement ${e} (--allow-entitlements)`); + if (entCheck.errors.length && !allowEnt) throw new Error(`the entitlements (from ${entSrc?.source ?? 'the sandbox defaults'}) do not belong in a Mac App Store build:\n${entCheck.errors.map(x => ` ${x}`).join('\n')}\n Point the build at the App Store plist: --entitlements , apple.macos.entitlements, or a tauri.appstore.conf.json overlay next to tauri.conf.json (--allow-entitlements builds anyway).`); if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)'); const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim()); - if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session (write the command into a .command file and \`open -a Terminal \`), or add --unsigned for a pipeline check.`); + if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`${sshBuildRefusal(remote, { unlockFlag: false })} + Or add --unsigned for a pipeline check.`); mkdirSync(out, { recursive: true }); writeFileSync(entFile, entitlementsXml(ent)); writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`); + if (prebuild && !prebuildSkip) await runPrebuild(prebuild, root, TAG); shipPhase('build', `tauri build ${target}, build ${buildNo}`); await runLogged(steps.tauri, tauri.cwd, join(out, 'tauri-build.log'), showCmd(steps.tauri, null), env); const appDir = findBuiltApp(tauri.dir, root, target, tauri.productName); @@ -174,9 +227,16 @@ export async function run(ctx: Ctx): Promise { const entOut = unsigned ? null : entitlementKeysOf(run1(['codesign', '-d', '--entitlements', ':-', appDir]).out); const leaks = pathFree || a.has('scan-paths') ? scanPathLeaks(appDir, leakNeedles(home, user, root)) : []; const checks = appProblems({ info: readAppInfo(appDir), entitlements: entOut, hasProfile: existsSync(join(appDir, 'Contents', 'embedded.provisionprofile')), leaks }, - { bundleId, version, build: buildNo, signed: !unsigned, pathFree }); + { bundleId, version, build: buildNo, signed: !unsigned, pathFree, allowEntitlements: allowEnt }); for (const w of checks.warnings) warn(TAG, w); if (checks.errors.length) throw new Error(`${basenameOf(appDir)} was built, but the Mac App Store would refuse it:\n${checks.errors.map(x => ` ${x}`).join('\n')}`); + if (appCheck) { + const cmd = preCheckCommand(appCheck, appDir); + log(TAG, `apple.macAppCheck: $ ${cmd}`); + const r = await runCheck(cmd, root); + if (!r.ok) throw new Error(`apple.macAppCheck failed (exit ${r.code}) on ${basenameOf(appDir)}; nothing packaged:\n${r.tail}`); + log(TAG, 'apple.macAppCheck: ok'); + } shipPhase('sign', 'productbuild'); rmSync(exportPath, { recursive: true, force: true }); mkdirSync(exportPath, { recursive: true }); diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 74f0056..1655081 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -1,8 +1,14 @@ // Archive and export an App Store IPA with xcodebuild, no Xcode UI. -// storebound apple build --platform macos --tauri [--path-free] [--unsigned] [--target T] [--profile F] [--version x.y.z] [--build-number N] [--out D] +// storebound apple build --platform macos --tauri [--path-free] [--unsigned] [--target T] [--profile F] [--entitlements ] [--allow-entitlements] [--version x.y.z] [--build-number N] [--out D] // (Mac App Store build of a Tauri app: cli/apple/build-mas.ts) // storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P] // [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json] +// [--derived-data ] [--path-free] [--scan-paths] +// [--keychain ] [--keychain-unlock] [--allow-ssh] [--allow-app-group-gap] +// Over SSH (no GUI session) codesign needs an unlocked keychain: --keychain-unlock unlocks it with the password in +// STOREBOUND_KEYCHAIN_PASSWORD (never printed) and sets its key partition list; --keychain signs with a build +// keychain instead of the login keychain. App Groups: stops before the archive when a target's App Group is not proven (manual: its +// installed profile; automatic: the App ID's capability and the newest profile the API lists; the portal assigns groups: console plan apple-app-group). // First the prebuild (build.prebuild / apple.prebuild: e.g. the web build + npx cap sync; once per flow run), then a // Capacitor project's copied web assets (ios/App/App/public) must be newer than the source, else it stops. // Finds the workspace / project and scheme (native, Capacitor ios/App, React Native ios/, Flutter ios/Runner, @@ -11,33 +17,43 @@ // profiles from apple.signing), runs xcodebuild archive and -exportArchive with the API key // (-allowProvisioningUpdates -authenticationKey*), and leaves /export/-/.ipa for `storebound apple upload`. Each build exports into its own // folder, cleaned first: xcodebuild refuses to overwrite an earlier App.ipa ("exportArchive Copy failed", pitfall 53). +// DerivedData: --derived-data (apple.build.derivedData) is xcodebuild -derivedDataPath; with --out (or apple.build.out) +// it defaults to /DerivedData, so a build is hermetic per output folder (without --out Xcode keeps ~/Library/...). +// --path-free (apple.build.pathFree) remaps the home folder, checkout, out and DerivedData out of the binary and the dSYM / +// Symbols (Swift -debug-prefix-map / -file-prefix-map, clang -ffile-prefix-map), then scans the archive and stops before the +// export when a home path is left; --scan-paths only scans and warns. A --out outside the home folder prints a hint. // Full xcodebuild output goes to /xcodebuild-*.log; the terminal gets the result lines. // Local only: nothing is sent to App Store Connect except reads (build numbers, team id, profiles). import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; +import { homedir, userInfo } from 'node:os'; import { dirname, join, relative } from 'node:path'; import { ascKeyFile } from '../lib/credentials'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; +import { derivedDataFor, archiveScanDirs, leakNeedles, leakProblem, outsideHome, pathFreeSettings, scanForPaths } from '../lib/path-free'; import { pump } from '../lib/runner'; import { parseMobileprovision, toXmlPlist } from '../lib/plist'; -import { nextBuildNumber } from '../lib/release'; +import { appNextBuild } from './next-build'; import { exportOptions, profileDirs } from '../lib/signing'; +import { type FamilyVerdict, familyVerdicts, projectFamilies } from '../lib/device-families'; +import { liveDeviceFamilies } from './device-families'; +import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain'; +import { appGroupSteps, appGroupVerdicts, type GroupEvidence } from '../lib/targets'; import { capacitorProject, webStep } from '../lib/web-assets'; -import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode'; +import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, recordLastBuild, scanXcode, showCmd } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; import { parsePlatform } from './platform'; -import { findBundleId } from './signing'; +import { discoverTargets, findBundleId, readGroupEvidence } from './signing'; import * as rt from '../lib/rt'; import { shipPhase } from '../lib/ship-step'; const TAG = 'build'; /** Installed App Store profiles for a bundle id, newest expiry first: { uuid, name, team }. */ -export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string }[] { - const out: { uuid: string; name: string; team: string; expires: string }[] = []; +export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string; entitlements: Record }[] { + const out: { uuid: string; name: string; team: string; expires: string; entitlements: Record }[] = []; for (const d of profileDirs(home)) { let files: string[] = []; try { files = readdirSync(d).filter(f => f.endsWith('.mobileprovision')); } catch { continue; } @@ -50,13 +66,35 @@ export function installedProfiles(bundleId: string, teamId: string | null, home const store = !p.ProvisionedDevices && !p.ProvisionsAllDevices && ent?.['get-task-allow'] !== true; if (!store || appId !== `${team}.${bundleId}` || (teamId && team !== teamId)) continue; if (Date.parse(String(p.ExpirationDate)) < Date.now()) continue; - if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate) }); + if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate), entitlements: ent ?? {} }); } catch { /* not a profile */ } } } return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires)); } +const security = (argv: string[]): { ok: boolean; out: string } => { const r = rt.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); return { ok: r.success, out: (r.stdout.toString() + r.stderr.toString()).trim() }; }; + +/** Puts a build keychain on the user's keychain search list (xcodebuild -exportArchive finds identities there). */ +function addToSearchList(keychain: string): void { + const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out); + const step = unlockSteps(keychain, list).find(x => x[1] === 'list-keychains'); + if (step) { const r = security(step); if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.slice(0, 200)}`); log(TAG, `${keychain} added to the keychain search list`); } +} + +/** --keychain-unlock: unlocks the keychain (the login keychain, or --keychain) with STOREBOUND_KEYCHAIN_PASSWORD and + * sets the key partition list so codesign uses the keys without a prompt. The password is never printed. */ +function unlockKeychain(keychain: string | null): void { + const pw = keychainPassword(process.env); + const kc = keychain ?? parseKeychainList(security(['security', 'default-keychain', '-d', 'user']).out)[0] ?? join(homedir(), 'Library/Keychains/login.keychain-db'); + const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out); + for (const step of unlockSteps(kc, list)) { + const r = security(step.map(x => (x === 'PASSWORD' ? pw : x))); + if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.split(pw).join('***').slice(0, 300)}`); + } + log(TAG, `keychain ${kc} unlocked, key partition list set (password from ${'STOREBOUND_KEYCHAIN_PASSWORD'})`); +} + export async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record): Promise { log(TAG, `$ ${show}`); log(TAG, `full output: ${logFile}`); @@ -119,6 +157,14 @@ export async function run(ctx: Ctx): Promise { if (sub) target.notes.unshift(`Capacitor project: ${sub} (${sub}/${cap!.config}; the only one found; set apple.build.workspace to use another)`); const configuration = a.str('configuration', cfg.configuration ?? 'Release'); const out = resolve(a.get('out') ?? cfg.out ?? 'build/ios'); + const outChosen = !!(a.get('out') ?? cfg.out); + const derivedData = derivedDataFor({ flag: a.get('derived-data'), config: cfg.derivedData, out, outChosen, resolve }); + const pathFree = a.has('path-free') || cfg.pathFree === true; + const scanPaths = pathFree || a.has('scan-paths'); + const home = homedir(); + const pfSettings = pathFree ? pathFreeSettings({ home, root, out, derivedData }) : []; + if (!pathFree && outsideHome(out, home)) warn(TAG, `--out is outside the home folder, but the build embeds the home folder and checkout paths (binary, dSYM, Symbols): add --path-free (or apple.build.pathFree) to remap them and scan the archive`); + if (pathFree && (!derivedData || !outsideHome(derivedData, home))) warn(TAG, `--path-free: DerivedData ${derivedData ? 'is' : 'defaults to'} inside the home folder, and dsymutil / Xcode record that path in the dSYM, so the scan below will find it. Build with --out (or --derived-data) outside the home folder, e.g. a shared folder.`); const version = a.get('version'); if (version && !/^\d+(\.\d+){0,2}$/.test(version)) throw new Error('--version: x.y.z'); @@ -126,12 +172,14 @@ export async function run(ctx: Ctx): Promise { let build = a.get('build-number'); let teamId = signing.teamId ?? null; if (!build || !teamId) { + // a line before each slow step: a run without a terminal (launchd job, CI) that stalls shows where + log(TAG, `reading App Store Connect (${[!build ? 'next build number' : '', !teamId ? 'team id' : ''].filter(Boolean).join(', ')})`); try { const c = await ascClient(root, bundleId, config.apple?.appId); if (!build) { - const builds = await c.all(`/v1/builds?filter[app]=${await c.app()}&limit=200&fields[builds]=version`); - build = nextBuildNumber(builds.map(b => String(b.attributes.version))); - log(TAG, `${builds.length} builds on App Store Connect: next build number ${build}`); + const found = await appNextBuild(c); + build = found.next; + log(TAG, `${found.builds} builds on App Store Connect: next build number ${build}`); } if (!teamId) teamId = (await findBundleId(c, bundleId))?.attributes?.seedId ?? null; } catch (e) { @@ -142,33 +190,73 @@ export async function run(ctx: Ctx): Promise { if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42'); const buildNo = build ?? ''; - const ids = [bundleId, ...(signing.extraBundleIds ?? [])]; + // device families: an upload that drops one the live version supports (iPad) is refused only AFTER the archive (ITMS-90101) + // so compare first: stop before building with the exact fix (--allow-device-gap builds anyway) + let deviceFamilies: FamilyVerdict[] = []; + log(TAG, 'reading the device families of the live version'); + try { + const c = await ascClient(root, bundleId, config.apple?.appId); + deviceFamilies = familyVerdicts({ live: await liveDeviceFamilies(c, await c.app()), project: projectFamilies(root, bundleId, target.project), ipad: config.apple?.ipad }); + } catch (e) { warn(TAG, `device families of the live version not compared (${(e as Error).message.split('\n')[0]?.slice(0, 120)})`); } + const familyBlockers = deviceFamilies.filter(d => d.level === 'blocker'); + for (const d of deviceFamilies) if (d.level === 'warn' || (d.level === 'blocker' && (dry || a.has('allow-device-gap')))) warn(TAG, `${d.msg}${d.fix ? ` -> ${d.fix}` : ''}`); + if (familyBlockers.length && !dry && !a.has('allow-device-gap')) + throw new Error(`stopped before the archive: ${familyBlockers.map(d => `${d.msg}. Fix: ${d.fix}`).join('\n')}\n(--allow-device-gap builds anyway)`); + + const scan = discoverTargets(root, config); + const ids = [bundleId, ...scan.extensions.map(t => t.bundleId)]; const profiles: Record = {}; if (style === 'manual') for (const id of ids) { const p = installedProfiles(id, teamId)[0]; if (p) profiles[id] = p.uuid; else if (!dry) throw new Error(`no installed App Store profile for ${id}: storebound apple signing ensure`); else warn(TAG, `no installed App Store profile for ${id} (storebound apple signing ensure)`); } + // App Groups: a profile carries a group only when the portal assigned it to the bundle id (the API cannot). Manual + // signing: the installed profile; automatic: the App ID's capability and the newest profile the API lists. A gap stops + // the build before the archive; an association nothing can prove is warned with the same steps. + const groupTargets = [...(scan.main ? [scan.main] : []), ...scan.extensions]; + let groupEvidence: ((id: string) => GroupEvidence | null) | null = null; + if (groupTargets.some(t => t.appGroups.length)) { + if (style === 'manual') groupEvidence = id => { const p = installedProfiles(id, teamId)[0]; return p ? { resourceId: null, capabilityOn: null, profileEnt: p.entitlements } : null; }; + else { + try { groupEvidence = await readGroupEvidence(await ascClient(root, bundleId, config.apple?.appId), groupTargets, 'automatic', signing.profileType ?? 'IOS_APP_STORE'); } + catch (e) { warn(TAG, `App Group association not read from App Store Connect (${(e as Error).message.split('\n')[0]?.slice(0, 120)})`); } + } + } + const groupVerdicts = appGroupVerdicts(groupTargets, groupEvidence ?? (() => null)); + for (const g of groupVerdicts.unproven) for (const l of appGroupSteps(g)) warn(TAG, l); + for (const g of groupVerdicts.gaps) { + const msg = `${g.bundleId}: ${g.why}`; + if (a.has('allow-app-group-gap') || dry) { warn(TAG, msg); for (const l of appGroupSteps(g).slice(1)) warn(TAG, l); } + else { + const auto = style === 'automatic' ? 'Automatic signing: Xcode renews its managed profile at archive time, so if you just ticked the group and the API still lists the old profile, build with --allow-app-group-gap.' : ''; + throw new Error([`${msg}; codesign would refuse the build (stopped before the archive).`, ...appGroupSteps(g).slice(1), ...(auto ? [auto] : []), '(--allow-app-group-gap builds anyway)'].join(String.fromCharCode(10))); + } + } const opts = exportOptions({ teamId: teamId ?? '', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '' }) : undefined }); const plist = toXmlPlist(opts); + const keychainFlag = a.get('keychain'); + const keychain = keychainFlag && keychainFlag !== 'true' ? resolve(keychainFlag) : null; + const unlock = a.has('keychain-unlock'); const archivePath = join(out, `${target.scheme}.xcarchive`); const exportPath = exportDir(out, version, buildNo); const authPreview: AscAuth = { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' }; const auth = style === 'automatic' ? authPreview : null; const steps = [ ...(target.framework === 'flutter' ? [showCmd(flutterConfigArgs(buildNo, version), null)] : []), - showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth }), auth), + showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth, settings: [...(keychain ? keychainSigningArgs(keychain) : []), ...pfSettings], derivedData }), auth), showCmd(exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, auth), auth), ]; + const unlockPreview = unlock ? unlockSteps(keychain ?? '', []).map(showSecurity) : []; const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration, - version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps, notes: target.notes }; + version: version ?? '(project value)', build: buildNo, deviceFamilies, derivedData: derivedData ?? '(Xcode default)', pathFree, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes }; const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') }; if (dry) { const web = await webStep({ ...webOpts, dry: true, quiet: json }); if (json) { printJson({ ...plan, web }); return; } log(TAG, `${target.framework}: ${target.workspace ?? target.project} scheme ${target.scheme} (${configuration}), build ${buildNo}${version ? `, version ${version}` : ''}, signing ${style}`); for (const n of target.notes) log(TAG, n); - for (const s of steps) log(TAG, `would run: ${s}`); + for (const s of plan.steps) log(TAG, `would run: ${s}`); console.log(`\n${relative(root, join(out, 'ExportOptions.plist'))}:\n${plist}`); done(TAG, 'build planned (dry run)'); return; @@ -177,21 +265,34 @@ export async function run(ctx: Ctx): Promise { // codesign in an SSH session cannot use the login keychain: the archive fails late with errSecInternalComponent // (Embed Pods Frameworks). Stop before the web build and the archive (pitfall 101). const remote = remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim()); - if (remote && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session: a Jenkins / Forgejo agent started in that session, or from SSH: write the command into a .command file and \`open -a Terminal \` (it runs in the GUI session; unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). --allow-ssh skips this check after \`security unlock-keychain\`.`); + if (remote && !a.has('allow-ssh') && !unlock && !keychain) throw new Error(sshBuildRefusal(remote)); + if (unlock || keychain) { + if (unlock) unlockKeychain(keychain); + else addToSearchList(keychain!); + } shipPhase('prebuild', 'web build / prebuild'); + log(TAG, 'web build / prebuild step'); const web = await webStep({ ...webOpts, dry: false }); for (const n of target.notes) log(TAG, n); const rsync = foreignRsync(process.env.PATH); if (rsync) log(TAG, `note: ${rsync} is first on your PATH; xcodebuild runs with /usr/bin first (a non-Apple rsync breaks Xcode's IPA export)`); mkdirSync(out, { recursive: true }); writeFileSync(join(out, 'ExportOptions.plist'), plist); + let leaks = 0; const key = style === 'automatic' ? ascKeyFile(root) : null; const realAuth: AscAuth | null = key ? { keyPath: key.keyPath, keyId: key.keyId, issuerId: key.issuerId } : null; try { if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null)); shipPhase('build', `xcodebuild archive, build ${build}`); - const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth }); + const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth, settings: [...(keychain ? keychainSigningArgs(keychain) : []), ...pfSettings], derivedData }); await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth)); + if (scanPaths) { + shipPhase('verify', 'scanning the archive for local paths'); + const found = scanForPaths(archiveScanDirs(archivePath), leakNeedles({ home, user: userInfo().username, root })); + leaks = found.total; + const why = leakProblem(found, relative(root, archivePath)); + if (why) { if (pathFree) throw new Error(why); warn(TAG, why.split(String.fromCharCode(10))[0]!); } else log(TAG, 'no local path in the archive (app, dSYMs, Symbols)'); + } shipPhase('sign', 'xcodebuild -exportArchive (signing)'); await exportIpa({ root, out, archivePath, exportPath, auth: realAuth }); } finally { key?.cleanup(); } @@ -205,9 +306,10 @@ export async function run(ctx: Ctx): Promise { // ITMS-91064 (Invalid Binary after the upload): NSPrivacyTracking / NSPrivacyTrackingDomains of every manifest in the IPA const privacy = bundleTrackingIssues(ipaManifests(join(exportPath, ipa))); if (privacy.length) throw new Error(`${relative(root, join(exportPath, ipa))} was built, but Apple would refuse it at upload:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest and build again.`); - const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle }; + const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle, derivedData: derivedData ?? null, pathFree, ...(scanPaths ? { leaks } : {}) }; writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)} `); + recordLastBuild(root, { ipa: result.ipa, out, version: info.version, build: info.build }); if (json) printJson(result); done(TAG, `${relative(root, result.ipa)}: ${info.bundleId} ${info.version} (${info.build}). Next: storebound apple upload ${relative(root, result.ipa)} --dry`); } diff --git a/cli/apple/creative-assets.ts b/cli/apple/creative-assets.ts index 7539022..78866a3 100644 --- a/cli/apple/creative-assets.ts +++ b/cli/apple/creative-assets.ts @@ -29,61 +29,17 @@ import { need } from '../lib/ctx'; import { consoleLink } from '../lib/console-links'; import { done, log, warn } from '../lib/log'; import { elapsed } from '../lib/progress'; -import * as rt from '../lib/rt'; import { ffprobe } from '../shots/report'; import { imageInfo } from '../shots/spec'; import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative'; import { type Asc, ascClient, md5, type J } from './asc'; +import { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, reason, REF, selectable, sumOf } from './asset-library'; +export { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, REF, selectable }; import { partHeaders } from './screenshot-health'; import { AmbiguousWriteError } from '../lib/upsert'; import { addItem, ensureDraft, OtherSubmissionError } from './submission'; const TAG = 'apple'; -export const REF = (sum: string): string => `storebound ${sum}`; -const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1]; - -/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */ -export const ASSET_STATES = { - /** still uploading / processing: wait (bounded), then select */ - processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'], - /** processed and usable in a slot; not reviewed yet: goes into a review submission */ - unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'], - /** in a submission or reviewed: usable in a slot, nothing to add */ - reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'], -} as const; -export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown'; - -/** What a library asset's state means for storebound. Pure. */ -export function classify(state: string | undefined): AssetClass { - const s = String(state ?? ''); - for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass; - if (s === 'FAILED') return 'failed'; - if (s === 'REJECTED') return 'rejected'; - if (s === 'ARCHIVED') return 'archived'; - return s ? 'unknown' : 'processing'; -} - -/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */ -export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing'; - -/** Stop re-uploading a file whose earlier uploads failed processing this many times. */ -export const MAX_FAILED = 2; - -/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing), - * else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */ -export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } { - const mine = assets.filter(x => sumOf(x) === sum); - const rank: Record = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 }; - const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c); - const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]); - if (live[0]) return { asset: live[0] }; - if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true }; - const rej = by('rejected')[0]; - if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` }; - if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` }; - return {}; -} - /** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later". * The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that * is not APPROVED waits; a later run places it. Pure. */ @@ -107,14 +63,6 @@ export async function creativeWaiting(c: Asc, appId: string, local: Map { - const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean); - return d.length ? ` (${d.join(', ')})` : ''; -}; - -/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */ -export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) }; - export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'reuse' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J } /** Local files per App Store locale, checked; throws on any error before a network call. */ diff --git a/cli/apple/device-families.ts b/cli/apple/device-families.ts new file mode 100644 index 0000000..2ae18c2 --- /dev/null +++ b/cli/apple/device-families.ts @@ -0,0 +1,41 @@ +// The device families of the app's last live iOS version, read from App Store Connect (cli/lib/device-families.ts compares +// them with the project and the config). Signals, merged because none is documented as THE answer: +// - the live version's build bundle: buildBundles.deviceProtocols and the device models of buildBundleFileSizes +// - the screenshot sets of the live version: an APP_IPAD_* display type means the version was submitted for iPad +// Live version = READY_FOR_SALE / READY_FOR_DISTRIBUTION; else the newest one that was on sale (REPLACED_WITH_NEW_VERSION). +import { compareVersions } from '../lib/listing-plan'; +import { liveFamilies, type LiveFamilies } from '../lib/device-families'; +import type { Asc, J } from './asc'; +import { type AscPlatform, versionsPath } from './platform'; + +const ON_SALE = ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION']; +const WAS_ON_SALE = [...ON_SALE, 'REPLACED_WITH_NEW_VERSION']; + +/** The live (or last live) version among the rows of GET appStoreVersions, newest first. Pure. */ +export function lastLiveVersion(rows: J[]): J | null { + const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState; + const pick = (states: string[]): J | null => rows.filter(v => states.includes(state(v))) + .sort((a, b) => compareVersions(String(b.attributes.versionString), String(a.attributes.versionString)))[0] ?? null; + return pick(ON_SALE) ?? pick(WAS_ON_SALE); +} + +/** Reads the families of the last live version of the platform (iOS unless named); null when there is none or no signal names a family. */ +export async function liveDeviceFamilies(c: Asc, appId: string, platform: AscPlatform = 'IOS'): Promise { + const v = lastLiveVersion(await c.all(versionsPath(appId, platform, 20))); + if (!v) return null; + const signals: { name: string; values: string[] }[] = []; + const b = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data; + if (b) { + const full = await c.maybe(`/v1/builds/${b.id}?include=buildBundles`); + const bundles = (full?.included ?? []).filter((x: J) => x.type === 'buildBundles' && (x.attributes?.bundleType ?? 'APP') === 'APP'); + signals.push({ name: `build ${b.attributes?.version ?? b.id} deviceProtocols`, values: bundles.flatMap((x: J) => (x.attributes?.deviceProtocols ?? []) as string[]) }); + const models: string[] = []; + for (const x of bundles) for (const f of await c.all(`/v1/buildBundles/${x.id}/buildBundleFileSizes?limit=200`)) models.push(String(f.attributes?.deviceModel ?? '')); + signals.push({ name: `build ${b.attributes?.version ?? b.id} device models`, values: models }); + } + const types: string[] = []; + for (const l of (await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`)).slice(0, 5)) + for (const s of await c.all(`/v1/appStoreVersionLocalizations/${l.id}/appScreenshotSets?limit=50`)) types.push(String(s.attributes?.screenshotDisplayType ?? '')); + signals.push({ name: 'screenshot sets', values: types }); + return liveFamilies(String(v.attributes.versionString), String(v.attributes.appVersionState ?? v.attributes.appStoreState), signals); +} diff --git a/cli/apple/duo-library.ts b/cli/apple/duo-library.ts new file mode 100644 index 0000000..76c2383 --- /dev/null +++ b/cli/apple/duo-library.ts @@ -0,0 +1,131 @@ +// iPhone Duo screenshots through the App Asset Library (App Store Connect API 4.5.1, pitfall 130). ScreenshotDisplayType +// has no Duo value and never will: Apple added the display class IPHONE_DUO only to AppAssetLibraryDisplayClass and +// deprecated appScreenshotSets / appScreenshots in the same release. Flow: +// 1. GET /v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT -> the iOS placementProfileGroup whose +// displayClassId is IPHONE_DUO (its id is the placementGroup of every Duo screenshot); +// 2. each file is uploaded once to the app's library (POST appAssetLibraryImages, category +// APP_SCREENSHOTS_AND_PREVIEWS, referenceName "storebound ", the same MD5 reuse as creative assets); +// 3. POST appAssetLibraryPlacements (placementType APP_SCREENSHOT, placementGroup, the version localization). +// Screenshots are reviewed with the version, so a processed asset is placed at once (no approval wait). +// Upsert: a group whose placements already show the local files in order is left alone; otherwise its placements are +// removed and the files placed in order (outer display files first, then inner). Never truncated: more files than +// the limit Apple publishes for the group (refData groupLimits) changes nothing and says so. Read back at the end. +import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; +import { log, warn } from '../lib/log'; +import { elapsed } from '../lib/progress'; +import { classify, pickAsset, processingTiming, REF, selectable } from './asset-library'; +import { type Asc, md5, type J } from './asc'; +import { partHeaders } from './screenshot-health'; + +const TAG = 'apple'; +export const DUO_CATEGORY = 'APP_SCREENSHOTS_AND_PREVIEWS'; +const sumOf = (a: J | undefined): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1]; + +/** iPhone placement profile group ids (platform IOS or IPHONE_APP_STORE, as the live API answers) of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */ +export function duoGroups(refData: J[]): string[] { + const ids = new Set(); + for (const r of refData) for (const g of (r?.attributes?.placementProfileGroups ?? []) as J[]) + if (g?.displayClassId === 'IPHONE_DUO' && (!g.platform || /IOS|IPHONE/.test(String(g.platform))) && g.placementProfileGroupId) ids.add(String(g.placementProfileGroupId)); + return [...ids]; +} + +/** Apple's screenshot limit for a placement group on a version page, or null when refData publishes none. Pure. */ +export function duoLimit(refData: J[], group: string): number | null { + for (const r of refData) for (const f of (r?.attributes?.features ?? []) as J[]) { + if (f?.featureId !== 'APP_STORE_VERSIONS') continue; + for (const p of (f.placementPolicies ?? []) as J[]) { + if (p?.placementType !== 'APP_SCREENSHOT') continue; + for (const l of (p.groupLimits ?? []) as J[]) if ((l?.groupIds ?? []).includes(group) && typeof l.maxCount === 'number') return l.maxCount; + } + } + return null; +} + +/** Whether a group's current placements (asset MD5s in order) already show `want`. Pure. */ +export const duoUpToDate = (shown: (string | undefined)[], want: string[]): boolean => shown.length === want.length && shown.every((s, i) => s === want[i]); + +export interface DuoResult { locale: string; action: 'keep' | 'place' | 'wait' | 'refuse' | 'plan'; files: number } + +/** Resolves the Duo placement group once per run; throws with the guardrail text when the API lists none. */ +export async function duoGroup(c: Asc): Promise<{ id: string; limit: number | null }> { + const ref = await c.all('/v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT'); + const g = duoGroups(ref); + if (!g.length) throw new Error('appAssetLibraryRefData lists no iOS IPHONE_DUO placement group for APP_SCREENSHOT: report it (pitfall 130); the console version page > App Previews and Screenshots > iPhone Duo takes them meanwhile'); + if (g.length > 1) log(TAG, `iPhone Duo: ${g.length} placement groups (${g.join(', ')}), using ${g[0]}`); + const limit = duoLimit(ref, g[0]!); + log(TAG, `iPhone Duo: placement group ${g[0]}, limit ${limit ?? 'not published'}`); + return { id: g[0]!, limit }; +} + +/** Makes the iPhone Duo screenshots of one version localization show `files` in order. */ +export async function syncDuo(c: Asc, o: { appId: string; group: string; limit: number | null; loc: J; files: string[]; dry: boolean }): Promise { + const locale: string = o.loc.attributes.locale; + const tag = `${locale} IPHONE_DUO`; + const files = o.files; + if (o.limit !== null && files.length > o.limit) { + warn(TAG, `${tag}: ${files.length} files (outer + inner) but Apple allows ${o.limit} in this group: nothing changed, none dropped. Keep at most ${o.limit} across iphone-duo-outer/ and iphone-duo-inner/`); + process.exitCode = 1; + return { locale, action: 'refuse', files: files.length }; + } + const lib = (await c.asc('GET', `/v1/apps/${o.appId}/assetLibrary`)).data as J; + const library = () => c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=${DUO_CATEGORY}&limit=200`); + let assets = await library(); + const want = files.map(f => md5(readFileSync(f))); + const placedAll = await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`); + const placed = placedAll.filter(p => p.attributes?.placementGroup === o.group); + const shown = placed.map(p => sumOf(assets.find(a => a.id === p.relationships?.image?.data?.id))); + if (duoUpToDate(shown, want)) { log(TAG, `${tag}: ${files.length} up to date`); return { locale, action: 'keep', files: files.length }; } + if (o.dry) { + const up = want.filter(s => !pickAsset(assets, s).asset).length; + log(TAG, `${tag}: would ${placed.length ? `replace ${placed.length} placed` : 'place'} ${files.length} screenshot(s) through the Asset Library (${up} to upload)`); + return { locale, action: 'plan', files: files.length }; + } + const chosen: J[] = []; + for (let i = 0; i < files.length; i++) { + const f = files[i]!, sum = want[i]!; + const pick = pickAsset(assets, sum); + if (pick.refuse) { warn(TAG, `${tag} ${basename(f)}: ${pick.refuse}`); return { locale, action: 'refuse', files: files.length }; } + let a = pick.asset; + if (!a) { log(TAG, `${tag}: uploading ${basename(f)} to the Asset Library`); a = await uploadImage(c, lib.id, f, sum); assets = await library(); a = pickAsset(assets, sum).asset ?? a; } + else if (pick.unarchive) a = (await c.asc('PATCH', `/v1/${a.type}/${a.id}`, { data: { type: a.type, id: a.id, attributes: { archived: false } } })).data as J; + a = await processed(c, a, `${tag} ${basename(f)}`); + const cls = classify(a.attributes?.state); + if (cls === 'processing') return { locale, action: 'wait', files: files.length }; + if (!selectable(cls)) { warn(TAG, `${tag} ${basename(f)}: ${a.attributes?.state}; nothing changed`); return { locale, action: 'refuse', files: files.length }; } + chosen.push(a); + } + // every file is ready before the old placements go, so a failed upload leaves the page as it was + for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`); + for (const a of chosen) await c.asc('POST', '/v1/appAssetLibraryPlacements', { data: { type: 'appAssetLibraryPlacements', attributes: { placementType: 'APP_SCREENSHOT', placementGroup: o.group }, + relationships: { image: { data: { type: 'appAssetLibraryImages', id: a.id } }, appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: o.loc.id } } } } }); + const now = (await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`)).filter(p => p.attributes?.placementGroup === o.group); + const ids = now.map(p => p.relationships?.image?.data?.id); + if (ids.length !== chosen.length || chosen.some(a => !ids.includes(a.id))) { warn(TAG, `${tag}: read back ${ids.length} placements, wanted ${chosen.length}`); process.exitCode = 1; } + else log(TAG, `${tag}: placed ${chosen.length} (removed ${placed.length})`); + return { locale, action: 'place', files: chosen.length }; +} + +async function uploadImage(c: Asc, libId: string, file: string, sum: string): Promise { + const buf = readFileSync(file); + const type = 'appAssetLibraryImages'; + const res = (await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: basename(file), fileSize: buf.length, category: DUO_CATEGORY, referenceName: REF(sum) }, + relationships: { assetLibrary: { data: { type: 'appAssetLibraries', id: libId } } } } })).data as J; + for (const op of (res.attributes.uploadOperations ?? []) as J[]) { + const r = await fetch(op.url, { method: op.method, headers: partHeaders(op), body: buf.subarray(op.offset, op.offset + op.length), signal: AbortSignal.timeout(300_000) }); + if (r.status < 200 || r.status > 299) { await c.asc('DELETE', `/v1/${type}/${res.id}`).catch(() => {}); throw new Error(`upload ${basename(file)} part ${op.offset} -> ${r.status} (reservation deleted, nothing committed)`); } + } + return (await c.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true } } })).data as J; +} + +async function processed(c: Asc, a: J, label: string): Promise { + const t0 = Date.now(); + let beat = t0, cur = a; + while (classify(cur.attributes?.state) === 'processing') { + if (Date.now() - t0 >= processingTiming.timeoutMs) { warn(TAG, `${label}: still ${cur.attributes?.state ?? 'processing'} after ${elapsed(Date.now() - t0)}; re-run later to place it`); return cur; } + await processingTiming.sleep(processingTiming.pollMs); + cur = ((await c.maybe(`/v1/${cur.type}/${cur.id}`))?.data as J) ?? cur; + if (Date.now() - beat >= processingTiming.heartbeatMs) { beat = Date.now(); log(TAG, `${label}: processing (${cur.attributes?.state}, ${elapsed(Date.now() - t0)})`); } + } + return cur; +} diff --git a/cli/apple/listing.ts b/cli/apple/listing.ts index 8fdeb45..8ccb325 100644 --- a/cli/apple/listing.ts +++ b/cli/apple/listing.ts @@ -5,7 +5,7 @@ // refuse with the reason), the build, and per locale create / update with old -> new per field. --dry stops there // (read-only); the real run asks in a terminal and applies exactly that plan. Locales marked status=draft or // status=translate (listing translate) are skipped unless --include-drafts. -// - the editable iOS version: versionString, copyright, release type MANUAL; with --build that processed build is attached +// - the editable iOS version: versionString, copyright, release type (apple.releaseType, MANUAL when new and unset); with --build that processed build is attached // - appInfoLocalizations: name, subtitle, privacy policy URL per locale // - appStoreVersionLocalizations: description, keywords, promotional text, what's new (not on a first version), // support + marketing URL per locale @@ -20,7 +20,8 @@ import { compactDiff } from '../lib/diff'; import { mergeAppleListing, overLimits } from '../lib/listing-pull'; import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, planLocales, planVersion, short, type Skipped, type VersionPlan } from '../lib/listing-plan'; import { createOnce, findOrCreate, getById } from '../lib/upsert'; -import { appleName } from '../lib/config'; +import { appleName, listingPathFor } from '../lib/config'; +import { wantedRelease } from '../lib/release-type'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; @@ -28,10 +29,10 @@ import { ascClient, EDITABLE, type J, noAppInfo } from './asc'; import { stdinIsTTY } from '../lib/tty'; import { allVersions, type AscPlatform, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform'; -export function readAppleCopy(ctx: Ctx) { +export function readAppleCopy(ctx: Ctx, platform?: string) { const { config, resolve } = ctx.loaded; const only = ctx.args.list('only'); - const all = parseAppStoreListing(readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8')); + const all = parseAppStoreListing(readFileSync(resolve(listingPathFor(config, platform)), 'utf8')); return Object.fromEntries(Object.entries(all).filter(([l]) => !only.length || only.includes(l))); } @@ -62,7 +63,7 @@ async function pull(ctx: Ctx): Promise { const at = l.attributes; remote[at.locale] = { ...remote[at.locale], description: at.description ?? undefined, keywords: at.keywords ?? undefined, promotionalText: at.promotionalText ?? undefined, whatsNew: at.whatsNew ?? undefined }; } - const file = resolve(config.apple?.listing ?? 'store/appstore.md'); + const file = resolve(listingPathFor(config, platform)); const before = existsSync(file) ? readFileSync(file, 'utf8') : '# App Store listing copy\n\nPulled from App Store Connect by `storebound apple listing pull`.\n'; const r = mergeAppleListing(before, remote, appleName(config)); const over = overLimits(r.changes, 'apple'); @@ -103,8 +104,8 @@ type Asc = Awaited>; async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, platform: AscPlatform): Promise { const { config, resolve } = ctx.loaded; const app = config.app; - const listing = readAppleCopy(ctx); - const md = readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8'); + const listing = readAppleCopy(ctx, platform); + const md = readFileSync(resolve(listingPathFor(config, platform)), 'utf8'); const drafts = draftSections(md, 'apple'); const include = ctx.args.has('include-drafts'); const skipped: Skipped[] = []; @@ -117,7 +118,8 @@ async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, pla } const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`; const rows = await c.all(versionsPath(appId, platform)); - const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' }, platform); + const rel = wantedRelease(config.apple); + const versionPlan = planVersion(rows, version, { copyright, releaseType: rel?.releaseType, earliestReleaseDate: rel?.earliestReleaseDate }, platform); let build: AppleListingPlan['build'] = null; const number = ctx.args.get('build'); @@ -155,6 +157,7 @@ export function listingPlanLines(p: AppleListingPlan): string[] { const v = p.versionPlan; const out = [`Version ${p.version} (${platformLabel(p.platform)}; App Store Connect has: ${v.existing.length ? v.existing.map(x => `${x.versionString} ${x.state} ${platformLabel(x.platform)}`).join(', ') : `no ${platformLabel(p.platform)} version yet`})`]; out.push(` ${v.action === 'refuse' ? 'REFUSE' : v.action.toUpperCase()}: ${v.reason}`); + if (v.warning) out.push(` WARN: ${v.warning}`); for (const ch of v.changes) out.push(` ${ch.field}: ${v.action === 'create' ? short(ch.to) : `${short(ch.from)} -> ${short(ch.to)}`}`); if (p.build) out.push(`Build: ${p.build.action === 'refuse' ? 'REFUSE: ' : ''}${p.build.reason}`); out.push(`App info ${p.appInfo.state ? `(${p.appInfo.state})` : ''}: name, subtitle, privacy policy URL per locale · ${counts(p.appInfo.locales)}`); diff --git a/cli/apple/next-build.ts b/cli/apple/next-build.ts index 5c4a560..530b28f 100644 --- a/cli/apple/next-build.ts +++ b/cli/apple/next-build.ts @@ -7,15 +7,25 @@ import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { printJson } from '../lib/log'; import { nextBuildNumber } from '../lib/release'; -import { ascClient } from './asc'; +import { ascClient, type Asc } from './asc'; + +/** + * The one place that computes the next CFBundleVersion: the highest build of the app over EVERY platform (iOS, macOS, + * tvOS, visionOS), every processing state and every page, plus one. CFBundleVersion must be unique across the app + * record's builds, so a platform filter (a macOS build once got a number an uploaded macOS build already had) or a + * missed page gives a number the store refuses. `apple next-build`, `apple build` and `apple build --platform macos` + * (also their dry runs) all call this, so they agree. + */ +export async function appNextBuild(c: Pick): Promise<{ next: string; builds: number }> { + const builds = await c.all(`/v1/builds?filter[app]=${await c.app()}&limit=200&fields[builds]=version`); + return { next: nextBuildNumber(builds.map(b => String(b.attributes.version))), builds: builds.length }; +} export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); - const appId = await c.app(); - const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version`); - const next = nextBuildNumber(builds.map(b => String(b.attributes.version))); - if (ctx.args.has('json')) { printJson({ next, builds: builds.length }); return; } - console.error(`[apple] ${builds.length} builds on App Store Connect; next build number:`); + const { next, builds } = await appNextBuild(c); + if (ctx.args.has('json')) { printJson({ next, builds }); return; } + console.error(`[apple] ${builds} builds on App Store Connect; next build number:`); console.log(next); } diff --git a/cli/apple/release.ts b/cli/apple/release.ts index 53c1680..5913c62 100644 --- a/cli/apple/release.ts +++ b/cli/apple/release.ts @@ -1,17 +1,31 @@ // Release management for an App Store version. // storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes] -// storebound apple release --manual-release [--version 1.2.0] [--yes] +// storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json] +// storebound apple release --after-approval | --manual | --scheduled [--version 1.2.0] [--first-release] [--yes] [--json] // Both take --platform ios|macos|tvos|visionos; without it the platform that has the version this needs is used (both: asks). // Phased release: 7-day automatic rollout to users with automatic updates (1, 2, 5, 10, 20, 50, 100 %); anyone can // still update by hand from the App Store. Pause holds it (up to 30 days in total), it is NOT a rollback: users who // updated keep the build. "complete" releases to everyone at once. -// --manual-release releases an approved version (PENDING_DEVELOPER_RELEASE). Public and irreversible: asks first. -// Neither --manual-release nor resume / complete is offered to agents through `storebound mcp`. +// --after-approval / --manual / --scheduled set how the version goes live (appStoreVersions.releaseType; the config's +// apple.releaseType does the same in `apple listing` and `apple submit`). Allowed until App Review approves the version +// (Prepare for Submission, rejected, Waiting for Review, In Review); an approved one (Pending Developer Release) is +// released with --manual-release, a released one is final. Setting it when it is that type already is exit 7 under the +// ship-step contract. An update of an app on sale is an agent step; --after-approval on the first version of an app +// puts it on sale by itself, so that one is the owner's (exit 3 OWNER_STEP unless --first-release). +// --manual-release releases an approved version (PENDING_DEVELOPER_RELEASE). Public and irreversible: asks first +// (--yes in scripts). Releasing an UPDATE of an app already on sale is an agent step (no owner gate, also in a +// pipeline; under the ship-step contract: exit 7 when the version is released already). The first-ever release of an +// app with --yes is exit 3 OWNER_STEP unless the owner approved it (--first-release, or bypass mode; +// cli/lib/release-kind.ts). Neither --manual-release nor resume / complete is offered to agents through `storebound mcp`. import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; -import { done, log } from '../lib/log'; +import { done, log, printJson } from '../lib/log'; +import { shipNoop, shipReport, shipRun } from '../lib/ship-step'; +import { consoleUrl } from '../lib/console-links'; +import { appleReleaseKind, enforceFirstRelease } from '../lib/release-kind'; import { type PhasedAction, phasedRequest } from '../lib/release'; import { createOnce } from '../lib/upsert'; +import { ASC_RELEASE_TYPE, changeText, RELEASE_TYPE_EDITABLE, releaseTypeChanges, type WantedRelease } from '../lib/release-type'; import { ascClient, type J } from './asc'; import { allVersions, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform'; @@ -20,13 +34,30 @@ export async function run(ctx: Ctx): Promise { const a = ctx.args; const phased = a.get('phased') as PhasedAction | undefined; const manual = a.has('manual-release'); - if (!phased && !manual) throw new Error('usage: apple release --phased on|pause|resume|complete | --manual-release [--version x.y.z] [--dry] [--yes]'); + const typeFlags = [a.has('after-approval') && 'afterApproval', a.has('manual') && 'manual', a.has('scheduled') && 'scheduled'].filter((x): x is 'afterApproval' | 'manual' | 'scheduled' => !!x); + if (typeFlags.length > 1) throw new Error('--after-approval, --manual and --scheduled are alternatives: give one'); + if (!phased && !manual && !typeFlags.length) throw new Error('usage: apple release --phased on|pause|resume|complete | --manual-release | --after-approval | --manual | --scheduled [--version x.y.z] [--dry] [--yes]'); + if (typeFlags.length && (phased || manual)) throw new Error('the release type flags (--after-approval, --manual, --scheduled) are a command of their own: run --phased / --manual-release separately'); if (phased && !['on', 'pause', 'resume', 'complete'].includes(phased)) throw new Error('--phased: on | pause | resume | complete'); const dry = a.has('dry'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); const want = a.get('version'); + if (typeFlags.length) return setReleaseType(ctx, c, appId, typeFlags[0]!); const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState; + shipReport({ version: want, url: consoleUrl('asc.version') }); + // the ship-step contract: the version is released already (and none waits for release): nothing to do, exit 7 + if (manual && shipRun()) { + const every = await allVersions(c, appId); + const waiting = every.some((x: J) => state(x) === 'PENDING_DEVELOPER_RELEASE' && (!want || x.attributes.versionString === want)); + const out = every.find((x: J) => ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION', 'PROCESSING_FOR_DISTRIBUTION'].includes(state(x)) && (!want || x.attributes.versionString === want)); + if (!waiting && out) { + shipNoop({ version: String(out.attributes.versionString), state: state(out) }); + if (a.has('json')) printJson({ version: String(out.attributes.versionString), state: state(out), released: true, alreadyThere: true }); + done('apple', `${String(out.attributes.versionString)} is ${state(out)} already: nothing released`); + return; + } + } // the platform: --platform, else the one with the version this needs (both: asks) const needs = (x: J): boolean => (!want || x.attributes.versionString === want) && (manual ? state(x) === 'PENDING_DEVELOPER_RELEASE' : phased === 'on' ? !['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'REPLACED_WITH_NEW_VERSION', 'REMOVED_FROM_SALE'].includes(state(x)) @@ -44,10 +75,19 @@ export async function run(ctx: Ctx): Promise { if (state(v) !== 'PENDING_DEVELOPER_RELEASE') throw new Error(`${tag}: only an approved version waiting for developer release can be released`); const pr = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreVersionPhasedRelease`))?.data; const how = pr ? 'phased over 7 days (phased release is on)' : 'to everyone at once (no phased release)'; - if (dry) { log('apple', `would release ${tag} ${how} (dry)`); return; } + const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: state(x) }))); + shipReport({ version: String(v.attributes.versionString), state: state(v) }); + if (dry) { + log('apple', `would release ${tag} ${how} (dry): ${kind.kind === 'update' ? 'update of an app on sale, no owner step' : 'FIRST release of this app (the owner approves it once: --first-release)'} (${kind.why})`); + if (a.has('json')) printJson({ version: String(v.attributes.versionString), state: state(v), releaseKind: kind.kind, dryRun: true }); + return; + } + if (!enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first release of the app', consoleUrl: consoleUrl('asc.version') })) return; if (!(await confirm(ctx, `Release ${tag} on the App Store now, ${how}? This is public.`))) return; await c.asc('POST', '/v1/appStoreVersionReleaseRequests', { data: { type: 'appStoreVersionReleaseRequests', relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: v.id } } } } }); + shipReport({ state: 'PENDING_APPLE_RELEASE' }); + if (a.has('json')) printJson({ version: String(v.attributes.versionString), state: 'PENDING_APPLE_RELEASE', releaseKind: kind.kind, released: true }); done('apple', `release requested for ${tag}; the store updates within a few hours`); return; } @@ -68,3 +108,52 @@ export async function run(ctx: Ctx): Promise { } done('apple', `${tag}: ${req.note}`); } + +/** apple release --after-approval | --manual | --scheduled: PATCH appStoreVersions releaseType on a version App Review has not approved yet. */ +async function setReleaseType(ctx: Ctx, c: Awaited>, appId: string, name: 'afterApproval' | 'manual' | 'scheduled'): Promise { + const a = ctx.args; + const TAG = 'apple'; + const want = a.get('version'); + const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState; + const date = name === 'scheduled' ? a.required('scheduled', 'ISO date and time', 'e.g. --scheduled 2027-03-01T09:00:00Z') : undefined; + if (date && Number.isNaN(Date.parse(date))) throw new Error(`--scheduled ${date}: an ISO 8601 date and time, e.g. 2027-03-01T09:00:00Z`); + const target: WantedRelease = { releaseType: ASC_RELEASE_TYPE[name], ...(date ? { earliestReleaseDate: date } : {}) }; + shipReport({ version: want, url: consoleUrl('asc.version') }); + const fits = (x: J): boolean => (!want || x.attributes.versionString === want) && (RELEASE_TYPE_EDITABLE.has(state(x)) || state(x) === 'PENDING_DEVELOPER_RELEASE'); + const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), want ? [fits, (x: J) => x.attributes.versionString === want] : fits, undefined, 'a version that is not approved yet'); + const versions = await c.all(versionsPath(appId, platform, 20)); + const v: J | undefined = want ? versions.find(x => x.attributes.versionString === want) : versions.find(x => RELEASE_TYPE_EDITABLE.has(state(x))) ?? versions.find(x => state(x) === 'PENDING_DEVELOPER_RELEASE'); + if (!v) throw new Error(`${platformLabel(platform)}: ${want ? `no version ${want}` : 'no version waiting for App Review (Prepare for Submission, rejected, Waiting for Review, In Review)'}`); + const versionString = String(v.attributes.versionString); + const tag = `${versionString} ${platformLabel(platform)} (${state(v)})`; + const changes = releaseTypeChanges(v.attributes, target); + const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: state(x) }))); + shipReport({ version: versionString, state: state(v) }); + const result = (extra: Record): void => { if (a.has('json')) printJson({ version: versionString, state: state(v), platform, releaseType: target.releaseType, from: v.attributes.releaseType ?? null, releaseKind: kind.kind, ...extra }); }; + if (!changes.length) { + shipNoop({ version: versionString, state: state(v) }); + result({ changed: false, alreadyThere: true }); + done(TAG, `${tag}: release type is ${target.releaseType} already: nothing changed`); + return; + } + if (!RELEASE_TYPE_EDITABLE.has(state(v))) { + throw new Error(`${tag}: the release type can no longer be changed${state(v) === 'PENDING_DEVELOPER_RELEASE' ? ` (App Review approved it and it waits for you: release it with storebound apple release --manual-release --version ${versionString})` : ' (it is approved or released; App Store Connect only takes the change before approval)'}`); + } + if (a.has('dry')) { + log(TAG, `would set the release type of ${tag}: ${changeText(changes)} (dry)`); + result({ changed: false, dryRun: true, changes }); + return; + } + // after approval = on sale at once: for the first version of an app that is the owner's step + if (target.releaseType === 'AFTER_APPROVAL' && !enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first release of the app (it goes on sale by itself once approved)', consoleUrl: consoleUrl('asc.version') })) return; + if (!(await confirm(ctx, `Set the release type of ${tag}: ${changeText(changes)}?`))) return; + try { + await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: Object.fromEntries(changes.map(ch => [ch.field, ch.to])) } }); + } catch (e) { + if (/-> 409\b/.test((e as Error).message)) throw new Error(`${tag}: App Store Connect refuses the release type change now (409): ${(e as Error).message} +It is editable until App Review approves the version; after that use apple release --manual-release.`); + throw e; + } + result({ changed: true, changes }); + done(TAG, `${tag}: release type ${changeText(changes)}`); +} diff --git a/cli/apple/resubmit.ts b/cli/apple/resubmit.ts index 9b23312..6bd2406 100644 --- a/cli/apple/resubmit.ts +++ b/cli/apple/resubmit.ts @@ -1,5 +1,5 @@ // Resubmit an App Store version after a rejection (Invalid Binary, Guideline 2.1, ...) with a new build. -// storebound apple resubmit --build [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json] +// storebound apple resubmit --build [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json] // The platform: --platform, else the one with a rejected version or an UNRESOLVED_ISSUES submission (both: asks). // 1. The rejected version (--version, else the one in the submission back from App Review, else the one in a // rejected state) gets build n attached, by explicit number (never "the latest"); it must be VALID. @@ -13,11 +13,15 @@ // 4. PATCH submitted: true. On "not ready yet" the items are read again: a REJECTED one left over stops the run with // the item and how to resolve it (retrying never passes); only when none is left is the submit retried with backoff // (right after a build is attached), with heartbeat lines and a 10-minute limit. -// Submitting is the owner's step: without --yes this is a dry run with the plan table. Not offered through MCP. +// Without --yes this is a dry run with the plan table. Submitting an UPDATE of an app that is already on sale (a version +// of the platform is or was READY_FOR_SALE) is an agent step: --yes sends it, no owner gate, also in a pipeline. The +// first-ever submission of an app is the owner's: with --yes it is exit 3 OWNER_STEP unless the owner approved it +// (--first-release, or bypass mode); cli/lib/release-kind.ts. Not offered through MCP. // `storebound apple submit --build ` (cli/apple/submit.ts) runs the same steps for a first submission: the version // is --version, else the one an open submission holds, else the editable one (Prepare for Submission or rejected). // Under `serve` (the desktop's owner action) the plan table is printed next to the --json result. -import { sellsOn } from '../lib/config'; +import { macosSubmitSkips, sellsOn } from '../lib/config'; +import { changeText, RELEASE_TYPE_EDITABLE, releaseTypeChanges, wantedRelease } from '../lib/release-type'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; @@ -30,6 +34,7 @@ import { openSubmissions } from './submission'; import { ensureGcSwitch, gameCenterInUse, gcSwitchPlan, readGcSwitch } from './game-center'; import { shipNoop, shipPhase, shipReport, shipRun } from '../lib/ship-step'; import { consoleUrl } from '../lib/console-links'; +import { appleReleaseKind, enforceFirstRelease } from '../lib/release-kind'; import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionsPath } from './platform'; /** App Store version states a first submission may send: the editable version (Prepare for Submission, a rejected @@ -81,6 +86,10 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom && ((mode === 'submit' ? SUBMITTABLE_APP : REJECTED_APP).has(stateOf(x)) || back.has(platformOf(x))); platform = pickPlatform(every, fits, undefined, mode === 'submit' ? `a version to submit${want ? ` (${want})` : ''}` : `a rejected version${want ? ` (${want})` : ''}`); } + // the Mac app of a two-platform record may sell nothing and have no Game Center (apple.macos): its submission then + // carries the version and build only + const macSkips = macosSubmitSkips(config, platform); + if (macSkips.iaps && ids.length) { log(TAG, `${ids.length} in-app purchase(s) left out: ${macSkips.iaps}`); ids.length = 0; } const versions = await c.all(versionsPath(appId, platform)); const open = await openSubmissions(c, appId, undefined, platform); const held = new Set(open.filter(s => s.state === 'UNRESOLVED_ISSUES').flatMap(s => s.items.filter(i => i.kind === 'appStoreVersion').map(i => i.versionId))); @@ -93,6 +102,10 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom if (mode === 'submit' && !SUBMITTABLE_APP.has(stateOf(v)) && !held.has(v.id)) throw new Error(`App Store version ${String(v.attributes.versionString)} (${pl}) is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`); const versionString = String(v.attributes.versionString); if (!a.has('json')) log(TAG, `app version ${versionString} (${pl}): ${stateOf(v)}`); + // apple.releaseType: the version goes live as the config says (afterApproval | manual | scheduled); set before the submit when it differs + const relWant = wantedRelease(config.apple); + const relChanges = relWant ? releaseTypeChanges(v.attributes, relWant) : []; + if (relChanges.length && !RELEASE_TYPE_EDITABLE.has(stateOf(v))) throw new Error(`App Store version ${versionString} (${pl}) is ${stateOf(v)}: its release type can no longer be changed (apple.releaseType wants ${changeText(relChanges)})`); // the build, by its number const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${versionString}&filter[preReleaseVersion.platform]=${platform}`))[0]; @@ -112,13 +125,15 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom const marks = state.choice?.action === 'reuse' ? toResolve(state.choice.sub.items, resolve).map(i => ({ item: i.id ?? null, kind: i.kind, what: labelOf(state, i.versionId, i.kind) })) : []; const blockers = [...(build.action === 'refuse' ? [build.reason] : []), ...plan.blockers]; const dry = a.has('dry') || !a.has('yes'); + const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: stateOf(x) }))); // Game Center: a build with the entitlement needs the app's Game Center detail and the version's switch, else the // submission answers 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED (seen live); the same upsert as apple game-center const gcUse = gameCenterInUse(config, root); - const gc = gcUse.inUse ? await readGcSwitch(c, appId, v) : null; + if (gcUse.inUse && macSkips.gameCenter) log(TAG, `Game Center switch left alone: ${macSkips.gameCenter}`); + const gc = gcUse.inUse && !macSkips.gameCenter ? await readGcSwitch(c, appId, v) : null; const gcPlan = gc ? gcSwitchPlan(gc) : []; if (a.has('json')) { - printJson({ dryRun: dry, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers }); + printJson({ dryRun: dry, releaseKind: kind.kind, releaseType: relChanges.length ? { changes: relChanges } : null, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers }); // the desktop's owner dialog shows the output text: the plan table goes with the result there if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); } if (blockers.length) process.exitCode = 1; @@ -126,6 +141,8 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom if (dry || blockers.length || state.choice?.action === 'busy') return; } else { console.log(formatRows(rows)); + log(TAG, `${kind.kind === 'update' ? 'update of an app on sale' : 'FIRST release of this app (the owner approves it once: --first-release)'}: ${kind.why}`); + if (relChanges.length) log(TAG, `${dry ? 'would set' : 'setting'} release type: ${changeText(relChanges)} (apple.releaseType)`); for (const l of gcPlan) log(TAG, `${dry ? 'would ' : ''}${l} (Game Center: ${gcUse.why})`); for (const s of plan.skipped) log(TAG, `not added ${s.id}: ${s.why}`); for (const x of blockers) warn(TAG, x); @@ -133,15 +150,20 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom if (blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; } if (state.choice?.action === 'busy') { shipNoop({ version: versionString, state: stateOf(v) }); done(TAG, `nothing to ${mode}: ${state.choice.why}`); return; } if (dry) { - done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${mode === 'submit' ? 'Submitting' : 'Resubmitting'} is the owner's step: after the owner's yes, run it again with --yes`); + done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${kind.kind === 'update' ? 'This is an update of an app on sale: run it again with --yes, no owner step' : `${mode === 'submit' ? 'Submitting' : 'Resubmitting'} the first release is the owner's step: after the owner's yes, run it again with --yes --first-release`}`); return; } + if (!enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first submission to App Review', consoleUrl: consoleUrl('asc.version') })) return; shipPhase('submit', `${mode === 'submit' ? 'submitting' : 'resubmitting'} ${versionString} with build ${number}`); if (build.action === 'attach') { await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: build.id } }); log(TAG, `build ${number} attached to ${versionString}`); } + if (relChanges.length) { + await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: Object.fromEntries(relChanges.map(ch => [ch.field, ch.to])) } }); + log(TAG, `release type of ${versionString}: ${changeText(relChanges)}`); + } if (gc && gcPlan.length) await ensureGcSwitch(c, gc, TAG); try { await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, platform, quiet: true, resolve }); diff --git a/cli/apple/review-details.ts b/cli/apple/review-details.ts index 123cc50..8a0a566 100644 --- a/cli/apple/review-details.ts +++ b/cli/apple/review-details.ts @@ -1,10 +1,11 @@ // App Review information on the editable version: contact, demo account, notes. Idempotent. -// storebound apple review-details [--contact-from ] [--dry] +// storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from ] [--dry] // The contact (name, phone, email) is personal data, so it is never in the config: it comes from // .storebound/credentials.json "reviewContact", or is copied from another app of the same team with --contact-from. // Demo account (reviewAccess.signInRequired: true): "reviewDemoAccount" in .storebound/credentials.json or // REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD; masked in every log line. Notes: apple.info.reviewNotes plus -// reviewAccess.instructions and accounts.inApp (lib/review-details.ts). +// reviewAccess.instructions and accounts.inApp (lib/review-details.ts). --platform macos: apple.macos.reviewNotes +// replaces apple.info.reviewNotes when set (a Mac app may have no Game Center, IAP or ads). import { refuseTemplate } from '../lib/template-answers'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; @@ -12,7 +13,8 @@ import { findReviewContact, maskSecret, maskUser, noContactMessage, reviewDemoAc import { reviewDetailPlan } from '../lib/review-details'; import { done, log } from '../lib/log'; import { createOnce } from '../lib/upsert'; -import { ascClient } from './asc'; +import { ascClient, EDITABLE, type J } from './asc'; +import { allVersions, parsePlatform, pickPlatform, platformLabel } from './platform'; type Attrs = Record; const shown = (k: string, v: unknown): string => { @@ -36,8 +38,10 @@ export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; refuseTemplate(config, ['reviewNotes', 'reviewAccess', 'accounts'], 'apple review-details (App Review notes, demo account requirement)'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); - const version = await c.editableVersion(); - if (!version) throw new Error('no editable App Store version (apple listing --version x.y.z creates it)'); + const appId = await c.app(); + const platform = parsePlatform(ctx.args.get('platform')) ?? pickPlatform(await allVersions(c, appId), (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), undefined, 'an editable version'); + const version = await c.editableVersion(platform); + if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (apple listing --version x.y.z creates it)`); let contact: Record | null = null; const from = ctx.args.get('contact-from'); @@ -57,7 +61,7 @@ export async function run(ctx: Ctx): Promise { } if (!contact) throw new Error(noContactMessage(root, checked)); - const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root)); + const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root), platform); const read = async () => (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data ?? null; const cur = await read(); if (ctx.args.has('dry')) { diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index fbb353f..f2439fd 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -1,12 +1,12 @@ // Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match // by MD5 and order is left alone, otherwise it is emptied and re-uploaded). -// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry] +// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry] // storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place) // Source per language: //ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67), // ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 -> // APP_IPAD_PRO_3GEN_129: the API has no "13 inch" value, the 13" display set keeps the 3rd gen 12.9" name). With // --platform macos (or when only the macOS version is editable) and apple.mac: mac/NN.png (2880 x 1800 -> APP_DESKTOP) -// to the macOS version. iPhone Duo sets are skipped with the console deep link (no API display type yet). Up to 10 +// to the macOS version. iPhone Duo (apple.iphoneDuo: iphone-duo-outer/ then iphone-duo-inner/) goes through the App Asset Library. Up to 10 // per set. --variant uploads ///... instead (an A/B variant from shots.variants, e.g. for a // Product Page Optimization treatment you then set up in App Store Connect). Apple processes uploads asynchronously; failed // ones are reported at the end. Long: run in the background with a log file. @@ -20,7 +20,8 @@ import { variantRoot } from '../shots/lib'; import { findOrCreate } from '../lib/upsert'; import { pulledChecksum } from '../lib/art-pull'; import { consoleLink } from '../lib/console-links'; -import { IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API } from '../shots/spec'; +import { IPHONE_DUO_DISPLAY } from '../shots/spec'; +import { duoGroup, syncDuo } from './duo-library'; import { type Asc, ascClient, EDITABLE, keptPrefix, md5, setUpToDate, type J } from './asc'; import { pendingScreenshots, repairPlan, repairSet, setRows, updateLedger } from './screenshot-health'; import { allVersions, parsePlatform, pickPlatform, platformLabel, stateOf, versionLabel } from './platform'; @@ -42,18 +43,17 @@ export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iph platform === 'MAC_OS' ? (apple?.mac ? MAC_DISPLAY : {}) : ({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}), ...(apple?.iphoneDuo ? Object.fromEntries(Object.entries(IPHONE_DUO_DISPLAY).filter((e): e is [string, string] => !!e[1])) : {}) }); -/** iPhone Duo size keys `apple screenshots` skips (apple.iphoneDuo on, no API display type yet), with the message. */ -export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } | undefined): { dirs: string[]; msg: string } | null { - const dirs = apple?.iphoneDuo ? Object.entries(IPHONE_DUO_DISPLAY).filter(([, t]) => !t).map(([k]) => k) : []; - // the console takes them (its own "iPhone Duo" tab): send the owner straight to the version page - return dirs.length ? { dirs, msg: `${dirs.join(', ')}: ${IPHONE_DUO_NOT_IN_API}. Add them in ${consoleLink('asc.version', '> App Previews and Screenshots > iPhone Duo', { appleAppId: apple?.appId })}` } : null; -} +/** iPhone Duo size keys (render folders) `apple screenshots` uploads through the App Asset Library (no + * ScreenshotDisplayType exists for Duo: cli/apple/duo-library.ts, pitfall 130), outer display first. */ +export const duoDirs = (apple: { iphoneDuo?: boolean } | undefined): string[] => (apple?.iphoneDuo ? Object.keys(IPHONE_DUO_DISPLAY) : []); /** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's * "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by * scaling a 6.9" or 6.5" set down (ACCEPTED_INSTEAD), plus iPad 13" display with ipad. iPhone Duo is optional. */ -export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] => - apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])]; +export const requiredDisplayTypes = (apple: { ipad?: boolean; mac?: boolean; screenshotTypes?: string[] } | undefined, platform: string = 'IOS'): string[] => + platform === 'MAC_OS' ? (apple?.mac ? Object.values(MAC_DISPLAY) : []) // a macOS version takes the Mac set only (apple.screenshotTypes is the iOS list) + : platform !== 'IOS' ? [] + : apple?.screenshotTypes ?? ['APP_IPHONE_61', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])]; /** Makes one screenshot set (of a version localization, a custom product page localization or a Product Page * Optimization treatment localization) hold exactly `files`, in order: left alone when the MD5s match, else the @@ -96,8 +96,10 @@ export async function run(ctx: Ctx): Promise { const version = await c.editableVersion(platform); if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (run apple listing --version x.y.z first)`); log('apple', `screenshots for version ${versionLabel(version)}`); - const duoSkip = platform === 'MAC_OS' ? null : skippedDuoSets(config.apple); - if (duoSkip) warn('apple', duoSkip.msg); + let duo = platform === 'MAC_OS' ? [] : duoDirs(config.apple); + // a Duo problem never stops the other sets + const duoGroupId = duo.length ? await duoGroup(c).catch((e: Error) => { warn('apple', `iPhone Duo skipped: ${e.message}`); duo = []; return null; }) : null; + const appId = duo.length ? await c.app() : ''; if (platform === 'MAC_OS' && !config.apple?.mac) throw new Error('macOS version: set apple.mac: true and render the mac size (storebound shots render --size mac) first'); if (platform !== 'MAC_OS' && config.apple?.mac) log('apple', 'Mac screenshots (mac/, APP_DESKTOP) go to the macOS version: run apple screenshots --platform macos'); const langOf = byAppleLocale(config.languages); @@ -119,12 +121,17 @@ export async function run(ctx: Ctx): Promise { if (!lang) { warn('apple', `${locale}: no language with apple: "${locale}", skipped`); continue; } tasks.push(async () => { const sets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`); - for (const [dir, type] of Object.entries(displaySets(config.apple, platform))) { + for (const [dir, type] of Object.entries(ctx.args.has('duo-only') ? {} : displaySets(config.apple, platform))) { const d = join(src, lang.id, dir); const files = numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10); if (!existsSync(d) || !files.length) { warn('apple', `${locale}: nothing in ${d}, skipped`); continue; } await syncSet(loc.id, sets, type, files, `${locale} ${type}`); } + if (duo.length && duoGroupId) { + const files = duo.flatMap(dir => numberedImages(join(src, lang.id, dir)).filter(f => f.endsWith('.png'))); + if (!files.length) warn('apple', `${locale}: nothing in ${duo.map(d => join(src, lang.id, d)).join(' or ')}, iPhone Duo skipped`); + else await syncDuo(c, { appId, group: duoGroupId.id, limit: duoGroupId.limit, loc, files, dry: ctx.args.has('dry') }).catch((e: Error) => { warn('apple', `${locale} iPhone Duo: ${e.message}`); process.exitCode = 1; }); + } }); } log('apple', `${tasks.length} locales, ${jobs} in parallel`); diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index cea09e0..22330b4 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -2,7 +2,13 @@ // provisioning profiles, so `apple build` can sign without anyone opening Xcode's Accounts settings. // storebound apple signing status [--json] read-only: capabilities, certificates (with / without a // private key on this machine), profiles, what is installed -// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json] +// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json] +// every signable target of the Xcode project (app, app extensions such as a share extension or widget, watch app, +// App Clip: PRODUCT_BUNDLE_IDENTIFIER + entitlements of project.pbxproj, or apple.signing.extensions) gets its +// bundle id registered, the capabilities its entitlements need and (manual style) an App Store profile. +// App Groups: the API cannot assign a group to a bundle id, so a profile lacks the group until it is ticked in the +// developer portal; ensure compares the entitlements with the profile and names the portal link and the steps; +// --refresh-profile makes the new profile after that. // adds missing capabilities (apple.signing.capabilities) and reads them back (fails when one did not stick). // style automatic (the default) stops there: Xcode owns certificates and profiles. style manual also // creates a distribution certificate when this machine @@ -20,14 +26,15 @@ import { confirm, need } from '../lib/ctx'; import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr'; import { done, log, printJson, warn } from '../lib/log'; import { - type ApiCert, type ApiProfile, type AppleSigning, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo, + type ApiCert, type ApiProfile, type AppleSigning, type CapabilitySpec, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo, ENTITLEMENT_CAPABILITIES, machoEntitlements, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType, compareCapabilities, deleteProfileCmd, ensureScope, explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, type ProfileType, type ProjectEntitlements, projectEntitlements, readProfiles, unusedStoreboundProfiles, verifyCapabilities, } from '../lib/signing'; import { isBuildCopyPath } from '../lib/privacy-manifest'; import { exportedIpas, ipaInfo, scanXcode } from '../lib/xcode'; -import { parsePlist } from '../lib/plist'; +import { parseMobileprovision, parsePlist } from '../lib/plist'; +import { appGroupSteps, appGroupVerdicts, type AppGroupGap, type GroupEvidence, type GroupVerdicts, pbxTargets, type TargetKind } from '../lib/targets'; import { zipEntries, zipRead } from '../lib/zip'; import type { ShipConfig } from '../lib/config'; import { textTable } from '../lib/table'; @@ -37,7 +44,7 @@ import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert'; import * as rt from '../lib/rt'; const TAG = 'signing'; -const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]'; +const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install] [--refresh-profile]'; export const signingDir = (root: string): string => join(root, '.storebound', 'signing'); @@ -64,6 +71,7 @@ export async function findBundleId(c: Asc, identifier: string): Promise b.attributes.identifier === identifier) ?? null; if (hit) hit.capabilities = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id)) .map((x: J) => x.attributes.capabilityType as string); + if (hit) hit.capabilityObjects = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id)).map((x: J) => x.attributes); return hit; } @@ -98,6 +106,119 @@ export function readProjectEntitlements(root: string): ProjectEntitlements[] | n return out; } +export interface SigningTarget { + bundleId: string; name: string; kind: TargetKind | 'configured'; source: 'xcode' | 'config'; + /** the .entitlements file read (relative to the repository), if any */ + entitlements: string | null; + capabilities: CapabilitySpec[]; + /** com.apple.security.application-groups of the entitlements */ + appGroups: string[]; +} +export interface TargetScan { main: SigningTarget | null; extensions: SigningTarget[]; notes: string[] } + +/** Every signable target of the iOS project: the app and its extensions / widgets / watch app / App Clip, read from the + * pbxproj (Release configuration) and their entitlements files. apple.signing.extensions replaces the detected + * extensions; apple.signing.extraBundleIds are added (no capabilities). Capabilities come from the entitlements: + * keychain-access-groups has no App ID capability (nothing to enable). Offline. */ +export function discoverTargets(root: string, config: ShipConfig): TargetScan { + const main = config.apple?.bundleId ?? ''; + const s: AppleSigning = config.apple?.signing ?? {}; + const notes: string[] = []; + const found: SigningTarget[] = []; + const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p)); + for (const proj of projects) { + let pbx = ''; + try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { continue; } + for (const t of pbxTargets(pbx)) { + if (!t.bundleId) { notes.push(`${proj}: target ${t.name} has bundle id "${t.unresolved ?? '?'}" which storebound cannot resolve (an xcconfig or a build variable): list it in apple.signing.extensions`); continue; } + if (found.some(f => f.bundleId === t.bundleId)) continue; + let caps: CapabilitySpec[] = [], groups: string[] = [], file: string | null = null; + if (t.entitlements) { + const f = join(dirname(join(root, proj)), t.entitlements); + try { + const xml = readFileSync(f, 'utf8'); + file = relative(root, f).split(sep).join('/'); + caps = projectEntitlements(file, xml, true).caps.map((c): CapabilitySpec => ({ type: c.cap })); + const g = (parsePlist(xml) as Record)['com.apple.security.application-groups']; + groups = Array.isArray(g) ? g.map(String) : []; + } catch { notes.push(`${proj}: target ${t.name}: entitlements file ${t.entitlements} not readable`); } + } + found.push({ bundleId: t.bundleId, name: t.name, kind: t.kind, source: 'xcode', entitlements: file, capabilities: caps, appGroups: groups }); + } + } + const mainTarget = found.find(t => t.bundleId === main) ?? null; + let extensions: SigningTarget[]; + if (s.extensions?.length) { + extensions = s.extensions.map((x): SigningTarget => { + const det = found.find(t => t.bundleId === x.bundleId); + return { bundleId: x.bundleId, name: x.name ?? det?.name ?? x.bundleId, kind: det?.kind ?? 'configured', source: 'config', entitlements: det?.entitlements ?? null, + capabilities: (x.capabilities ?? det?.capabilities ?? []).map((c): CapabilitySpec => (typeof c === 'string' ? { type: c } : c)), appGroups: x.appGroups ?? det?.appGroups ?? [] }; + }); + notes.push(`apple.signing.extensions replaces the ${found.filter(t => t.bundleId !== main).length} extension target(s) found in the Xcode project`); + } else extensions = found.filter(t => t.bundleId !== main); + for (const b of s.extraBundleIds ?? []) if (b !== main && !extensions.some(t => t.bundleId === b)) extensions.push({ bundleId: b, name: b, kind: 'configured', source: 'config', entitlements: null, capabilities: [], appGroups: [] }); + return { main: mainTarget, extensions, notes }; +} + +/** Name of a bundle id on the developer portal (letters, digits, spaces). */ +export const bundleIdName = (appName: string, t: SigningTarget): string => `${appName} ${t.name}`.replace(/[^A-Za-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60); + +const platformOf = (type: ProfileType): 'IOS' | 'MAC_OS' => (type.startsWith('MAC_') ? 'MAC_OS' : 'IOS'); + +/** The entitlements of the newest ACTIVE profile of a bundle id that the API serves with its content (a managed one + * is not readable: null = unknown). */ +export function profileEntitlements(list: ApiProfile[]): Record | null { + const p = list.filter(x => !x.managed && x.attributes.profileState === 'ACTIVE' && x.attributes.profileContent) + .sort((a, b) => Date.parse(b.attributes.expirationDate ?? '') - Date.parse(a.attributes.expirationDate ?? ''))[0]; + if (!p) return null; + try { return (parseMobileprovision(Buffer.from(p.attributes.profileContent!, 'base64')).Entitlements ?? {}) as Record; } catch { return null; } +} + +/** The entitlements of the newest ACTIVE profile of a bundle id with content, an Xcode-managed one too (automatic + * signing: the list serves the content even where /v1/profiles/{id} answers 404). null = none readable. */ +export function newestProfileEntitlements(list: ApiProfile[]): Record | null { + const p = list.filter(x => x.attributes.profileState === 'ACTIVE' && x.attributes.profileContent) + .sort((a, b) => Date.parse(b.attributes.expirationDate ?? '') - Date.parse(a.attributes.expirationDate ?? ''))[0]; + if (!p) return null; + try { return (parseMobileprovision(Buffer.from(p.attributes.profileContent!, 'base64')).Entitlements ?? {}) as Record; } catch { return null; } +} + +/** What the API shows about the App Group association of one bundle id: the APP_GROUPS capability (and its settings) + * and the entitlements of its newest readable profile (manual signing: storebound's own; automatic: any, managed too). */ +export function groupEvidence(b: J | null | undefined, list: ApiProfile[], style: 'automatic' | 'manual'): GroupEvidence | null { + if (!b) return null; + const caps = (b.capabilities as string[] | undefined) ?? []; + const obj = ((b.capabilityObjects as { capabilityType?: string; settings?: unknown }[] | undefined) ?? []).find(x => x.capabilityType === 'APP_GROUPS'); + return { resourceId: b.id ?? null, capabilityOn: caps.includes('APP_GROUPS'), capabilitySettings: obj?.settings ?? null, + profileEnt: style === 'manual' ? profileEntitlements(list) : newestProfileEntitlements(list) }; +} + +/** Reads the evidence for every target with App Groups (API only, no writes): the bundle id with its capabilities and + * the profiles listed under it. */ +export async function readGroupEvidence(c: Asc, targets: { bundleId: string; appGroups: string[] }[], style: 'automatic' | 'manual', type: ProfileType): Promise<(bundleId: string) => GroupEvidence | null> { + const out = new Map(); + for (const t of targets.filter(x => x.appGroups.length)) { + const b = await findBundleId(c, t.bundleId); + let list: ApiProfile[] = []; + if (b) { + try { list = (await c.all(`/v1/bundleIds/${b.id}/profiles?limit=200`) as J[]).filter(p => p.attributes?.profileType === type).map((p): ApiProfile => ({ id: p.id, attributes: p.attributes })); } catch { /* not listable: unproven */ } + } + out.set(t.bundleId, groupEvidence(b, list, style)); + } + return id => out.get(id) ?? null; +} + +export function warnAppGroupGaps(gaps: AppGroupGap[], tag: string): void { + for (const g of gaps) for (const l of appGroupSteps(g)) warn(tag, l); +} + +/** Early exit text of a proven App Group gap (before the archive / at the end of ensure): the gap, the exact portal + * steps with the deep link, the plan command and the refresh. */ +export function appGroupError(gaps: AppGroupGap[], what: string): string { + const nl = String.fromCharCode(10); + return `${gaps.map(g => `${g.bundleId}: ${g.why ?? `lacks ${g.groups.join(', ')}`}`).join('; ')}; ${what}.${nl}${gaps.map(g => appGroupSteps(g).slice(1).join(nl)).join(nl)}`; +} + const git = (root: string, args: string[]): string | null => { try { const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' }); @@ -171,7 +292,9 @@ export async function capabilityReport(c: Asc, config: ShipConfig, root: string) const b = await findBundleId(c, bundleId); const appId = b ? (b.capabilities as string[]) : null; const cfg = capabilitySpecs(config.apple?.signing, config.games?.gameCenter === true).map(x => x.type); - const project = readProjectEntitlements(root); + // an extension's entitlements belong to its own bundle id (signing ensure handles it), not to the app's App ID + const extFiles = new Set(discoverTargets(root, config).extensions.map(t => t.entitlements).filter((f): f is string => !!f)); + const project = readProjectEntitlements(root)?.filter(p => !extFiles.has(p.file)) ?? null; let build: BuildEntitlements | null = null; try { build = builtIpaEntitlements(root, config); } catch { /* unreadable IPA: compared without it */ } const checkout = project && cfg.some(x => Object.values(ENTITLEMENT_CAPABILITIES).includes(x as CapabilityType)) && !project.some(p => p.caps.length) ? checkoutInfo(root) : null; @@ -219,6 +342,11 @@ export interface SigningState { capabilities: { present: string[]; missing: string[]; extra: string[] }; certificate: { type: CertType; chosen: string | null; why: string; onTeam: number; withKey: number; limit: number | null }; profiles: { bundleId: string; name: string | null; uuid: string | null; state: string; installed: boolean; why: string }[]; + /** every signable target: where it was found, its capabilities (present / missing on its App ID) and App Groups */ + targets: { bundleId: string; name: string; kind: string; source: string; registered: boolean; capabilities: string[]; missingCapabilities: string[]; appGroups: string[] }[]; + appGroupGaps: AppGroupGap[]; + /** targets whose App Group association the API cannot show (warned, not blocked) */ + appGroupUnproven: AppGroupGap[]; } export async function run(ctx: Ctx): Promise { @@ -237,7 +365,13 @@ export async function run(ctx: Ctx): Promise { const c = await ascClient(root, bundleId, config.apple?.appId); // --- read everything first (read-only) - const ids = [bundleId, ...(s.extraBundleIds ?? [])]; + const scan = discoverTargets(root, config); + const exts = scan.extensions; + const ids = [bundleId, ...exts.map(t => t.bundleId)]; + const targetOf = (id: string): SigningTarget | undefined => (id === bundleId ? scan.main ?? undefined : exts.find(t => t.bundleId === id)); + const refresh = a.has('refresh-profile'); + const refreshIds = new Set(ids.filter(id => (targetOf(id)?.appGroups.length ?? 0) > 0)); + if (refresh && !refreshIds.size) ids.forEach(id => refreshIds.add(id)); const bundles = new Map(); for (const id of ids) bundles.set(id, await findBundleId(c, id)); const main = bundles.get(bundleId); @@ -257,8 +391,11 @@ export async function run(ctx: Ctx): Promise { const b = bundles.get(id); if (!b) { profiles.set(id, { list: [], plan: null }); continue; } const list = await profilesOf(c, b.id, profileType); - profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType }) : null }); + profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType, refresh: refresh && refreshIds.has(id) }) : null }); } + const wantCaps = (t: SigningTarget): CapabilitySpec[] => (t.bundleId === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true) : t.capabilities); + const targetList: SigningTarget[] = [...(scan.main ? [scan.main] : []), ...exts]; + const verdictsOf = (): GroupVerdicts => appGroupVerdicts(targetList, id => groupEvidence(bundles.get(id), profiles.get(id)?.list ?? [], style)); const state: SigningState = { bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main, capabilities: { present: capPlan.present, missing: capabilitySpecs(s, config.games?.gameCenter === true).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra }, @@ -266,7 +403,7 @@ export async function run(ctx: Ctx): Promise { onTeam: certs.filter(x => Date.parse(x.attributes.expirationDate ?? '') > Date.now()).length, withKey: choice.usable.length, limit: CERT_LIMIT[certType] ?? null }, profiles: ids.map(id => { const p = profiles.get(id)!; - if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: 'register it first (storebound apple bundle for the app; extensions in Certificates, Identifiers & Profiles)' }; + if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: id === bundleId ? 'register it first (storebound apple bundle)' : 'storebound apple signing ensure registers it' }; const r = p.plan?.reuse; const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE'); if (style === 'automatic') @@ -277,8 +414,17 @@ export async function run(ctx: Ctx): Promise { return { bundleId: id, name: r?.attributes.name ?? null, uuid: r?.attributes.uuid ?? null, state: r ? 'ACTIVE' : 'needs a new profile', installed: installedAt(r?.attributes.uuid).length > 0, why: p.plan?.why ?? 'waits for a usable certificate' }; }), + targets: [], appGroupGaps: [], appGroupUnproven: [], }; + state.targets = ids.map(id => { + const t = targetOf(id), b = bundles.get(id); + const want = t ? wantCaps(t).map(w => w.type) : id === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type) : []; + return { bundleId: id, name: t?.name ?? id, kind: t?.kind ?? 'app', source: t?.source ?? 'config', registered: !!b, capabilities: want, + missingCapabilities: want.filter(w => !(b?.capabilities ?? []).includes(w)), appGroups: t?.appGroups ?? [] }; + }); + { const v = verdictsOf(); state.appGroupGaps = v.gaps; state.appGroupUnproven = v.unproven; } + // storebound-made profiles that nothing uses (automatic signing: all of them; manual: all but the reused one) const inUse = new Set(style === 'manual' ? [...profiles.values()].map(p => p.plan?.reuse?.id).filter((x): x is string => !!x) : []); const unused = [...profiles.values()].flatMap(p => unusedStoreboundProfiles(p.list, inUse)); @@ -296,6 +442,9 @@ export async function run(ctx: Ctx): Promise { log(TAG, `capabilities: ${state.capabilities.present.join(', ') || 'none configured'}${state.capabilities.missing.length ? `; missing ${state.capabilities.missing.join(', ')}` : ''}${state.capabilities.extra.length ? `; also on the bundle id: ${state.capabilities.extra.join(', ')}` : ''}`); log(TAG, `certificate ${certType}: ${choice.why} (${state.certificate.onTeam} valid on the team${state.certificate.limit ? `, limit ${state.certificate.limit}` : ''})`); for (const x of choice.expiring) warn(TAG, `${x.attributes.name ?? x.id} expires ${x.attributes.expirationDate?.slice(0, 10)}: make a new one soon (storebound apple signing ensure)`); + for (const n of scan.notes) log(TAG, n); + console.log(textTable(['target', 'kind', 'bundle id', 'registered', 'capabilities', 'app groups'], state.targets.map(t => [t.name, t.kind, t.bundleId, t.registered ? 'yes' : 'no', t.capabilities.join(', ') || '-', t.appGroups.join(', ') || '-']))); + warnAppGroupGaps([...state.appGroupGaps, ...state.appGroupUnproven], TAG); console.log(textTable(['bundle id', 'profile', 'state', 'installed', ''], state.profiles.map(p => [p.bundleId, p.name ?? '', p.state, p.installed ? 'yes' : 'no', p.why]))); printCapabilityReport(report, TAG); cleanup(); @@ -305,11 +454,19 @@ export async function run(ctx: Ctx): Promise { // --- ensure: plan, show, ask once, then write if (!main) throw new Error(`bundle id ${bundleId} is not registered: storebound apple bundle --dry, then without --dry`); - const missingExtra = ids.filter(id => !bundles.get(id)); - if (missingExtra.length) throw new Error(`not registered: ${missingExtra.join(', ')} (register extension bundle ids in Certificates, Identifiers & Profiles first)`); + for (const n of scan.notes) log(TAG, n); + // extensions: register the bundle id (not there yet), then the capabilities its entitlements need + const toRegister = exts.filter(t => !bundles.get(t.bundleId)); + const registerReq = (t: SigningTarget): Req => ({ method: 'POST', path: '/v1/bundleIds', note: `register ${t.bundleId} (${t.kind} ${t.name})`, + body: { data: { type: 'bundleIds', attributes: { identifier: t.bundleId, name: bundleIdName(config.app.name, t), platform: platformOf(profileType) } } } }); + const extCapPlans = exts.map(t => { const b = bundles.get(t.bundleId); return { t, b, plan: b ? planCapabilities(b.id, b.capabilities ?? [], t.capabilities) : null }; }); + const extCapLines = extCapPlans.flatMap(({ t, b, plan: cp }) => (b ? (cp?.create ?? []).map(r => `${describeReq(r)} # ${t.bundleId}`) + : t.capabilities.map(w => `POST /v1/bundleIdCapabilities {capabilityType="${w.type}"} # ${t.bundleId}, after it is registered`))); const needCert = scope.certificates && !choice.cert; const plan: string[] = [ + ...toRegister.map(t => `${describeReq(registerReq(t))}`), ...capPlan.create.map(r => describeReq(r)), + ...extCapLines, ...(needCert ? [describeReq(certificateReq(certType, ''))] : []), ...(!scope.profiles ? [] : ids.flatMap(id => { const p = profiles.get(id)!.plan; @@ -330,6 +487,7 @@ export async function run(ctx: Ctx): Promise { if (scope.profiles) for (const p of state.profiles) if (p.uuid && !p.installed && !a.has('no-install')) log(TAG, `would install profile ${p.name} (${p.uuid}) for Xcode`); if (capPlan.create.length) log(TAG, `would read the App ID's capabilities back after the write and fail if ${capPlan.create.map(r => (r.body as J).data.attributes.capabilityType).join(' / ')} is not there`); printCapabilityReport(report, TAG); + warnAppGroupGaps([...state.appGroupGaps, ...state.appGroupUnproven], TAG); cleanup(); done(TAG, `signing checked (dry run): ${plan.length} write(s)`); return; @@ -339,10 +497,30 @@ export async function run(ctx: Ctx): Promise { const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG }); if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type), responses); if (gcPlan.length && gcSw) await ensureGcSwitch(c, gcSw, TAG); + for (const t of toRegister) { + const made = (await createOnce({ what: `${t.bundleId} bundle id`, tag: TAG, + find: async () => findBundleId(c, t.bundleId), + create: async () => (await c.asc('POST', '/v1/bundleIds', registerReq(t).body)).data })).value; + bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? { ...made, capabilities: [] }); + log(TAG, `${t.bundleId}: bundle id registered (${t.kind} ${t.name})`); + } + for (const { t } of extCapPlans) { + const bid = bundles.get(t.bundleId)!; + const cp = planCapabilities(bid.id, bid.capabilities ?? [], t.capabilities); + if (!cp.create.length) continue; + const rs = await execute(c.asc, cp.create, { dry: false, tag: TAG }); + await checkCapabilitiesStuck(c, t.bundleId, t.capabilities.map(w => w.type), rs); + } if (!scope.profiles) { printCapabilityReport(await capabilityReport(c, config, root), TAG); cleanup(); - if (json) printJson({ ...state, style, scope, plan, done: true }); + // automatic signing: Xcode makes the profile at archive time, so the App Group association is read from the App ID + // and the newest profile the API lists now (the writes above may have added capabilities: read the App IDs again) + for (const t of targetList) if (t.appGroups.length) bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? bundles.get(t.bundleId) ?? null); + const v = appGroupVerdicts(targetList, id => groupEvidence(bundles.get(id), profiles.get(id)?.list ?? [], 'automatic')); + if (json) printJson({ ...state, style, scope, plan, appGroupGaps: v.gaps, appGroupUnproven: v.unproven, done: !v.gaps.length }); + warnAppGroupGaps(v.unproven, TAG); + if (v.gaps.length) throw new Error(`capabilities in step, but ${appGroupError(v.gaps, 'the archive would fail at codesign (empty application-groups in the profile)')}`); done(TAG, `capabilities in step; ${scope.why.split(':')[0]}: nothing else to create`); return; } @@ -385,10 +563,11 @@ export async function run(ctx: Ctx): Promise { else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)'); cert = created; } + const finalEnt = new Map>(); for (const id of ids) { const b = bundles.get(id)!; const list = profiles.get(id)!.list; - const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType }); + const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType, refresh: refresh && refreshIds.has(id) }); let prof: ApiProfile; if (p.create) { // an unclear answer is checked by reading the bundle id's profiles again (one active profile with this @@ -407,9 +586,16 @@ export async function run(ctx: Ctx): Promise { log(TAG, `${id}: ${p.why}`); } if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`); + try { finalEnt.set(id, (parseMobileprovision(Buffer.from(prof.attributes.profileContent ?? '', 'base64')).Entitlements ?? {}) as Record); } catch { /* unreadable: not compared */ } } cleanup(); - if (json) printJson({ ...state, plan, done: true }); + // App Groups: the profile carries a group only when it is assigned to the bundle id in the developer portal + for (const t of targetList) if (t.appGroups.length) bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? bundles.get(t.bundleId) ?? null); + const v = appGroupVerdicts(targetList, id => { const ent = finalEnt.get(id); const b = bundles.get(id); return ent ? { ...groupEvidence(b, [], 'manual')!, profileEnt: ent } : groupEvidence(b, [], 'manual'); }); + const gaps = v.gaps; + if (json) printJson({ ...state, plan, appGroupGaps: gaps, appGroupUnproven: v.unproven, done: !gaps.length }); + warnAppGroupGaps(v.unproven, TAG); + if (gaps.length) throw new Error(`profile(s) installed, but ${appGroupError(gaps, 'the build would fail at codesign')}`); done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)'); } diff --git a/cli/apple/testflight.ts b/cli/apple/testflight.ts index 63c272c..35c7628 100644 --- a/cli/apple/testflight.ts +++ b/cli/apple/testflight.ts @@ -26,7 +26,7 @@ import { BUILD_WAIT_EXIT, type BuildSeen, expireCandidates, planExpire, validHin import { parseDuration } from '../lib/release'; import { type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, getsAllBuilds, type Group, groupFromApi, inviteReq, maskEmail, - parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, whatToTestFromListing, + installableBuild, noInstallableBuildHint, noInstallableBuilds409, parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, whatToTestFromListing, } from '../lib/testflight'; import { type Asc, ascClient, type J } from './asc'; import { describeReq, execute, type Req } from './jsonapi'; @@ -37,8 +37,8 @@ const TAG = 'testflight'; const USAGE = 'usage: storebound apple testflight status | groups [sync] | testers | what-to-test --build N | info | distribute --build N --group | wait --build N | expire --keep N | --older-than D'; /** The state of build `number` (of `marketing` version) right now; found: false while App Store Connect does not list it. */ -async function seeBuild(c: Asc, appId: string, number: string, marketing?: string): Promise { - const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': 'IOS', include: 'preReleaseVersion,buildBetaDetail', limit: '50' }); +async function seeBuild(c: Asc, appId: string, number: string, marketing?: string, platform = 'IOS'): Promise { + const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': platform, include: 'preReleaseVersion,buildBetaDetail', limit: '50' }); if (marketing) q.set('filter[preReleaseVersion.version]', marketing); const resp = await c.asc('GET', `/v1/builds?${q}`); const hits = buildsWithVersion(resp).filter(b => b.attributes.version === number && (!marketing || b.marketing === marketing)); @@ -54,13 +54,13 @@ export interface BuildWait { outcome: WaitOutcome; timedOut: boolean; seen: Buil * Polls build `number` until processing ends (VALID, or FAILED / INVALID) or `timeoutMs` passes; logs each state change. * `once` reads the state a single time. Shared by `testflight wait` and `apple upload` (which waits after the upload). */ -export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; timeoutMs: number; once?: boolean; tag?: string } & HeartbeatOptions): Promise { +export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; platform?: 'IOS' | 'MAC_OS'; timeoutMs: number; once?: boolean; tag?: string } & HeartbeatOptions): Promise { const started = Date.now(); let delay: number | null = null, last = ''; // polls are up to 2 minutes apart: a heartbeat in between says the wait is alive const beat = o.once ? null : heartbeat(ms => log(o.tag ?? TAG, `still waiting for build ${number}: ${last || 'first check'}, ${elapsed(ms)} of ${elapsed(o.timeoutMs)}`), o); try { for (;;) { - const seen = await seeBuild(c, appId, number, o.marketing); + const seen = await seeBuild(c, appId, number, o.marketing, o.platform); const outcome = waitOutcome(seen); const line = outcome === 'missing' ? 'not listed yet (App Store Connect lists it once processing starts)' : `${seen.processingState}${seen.internal ? `, internal ${seen.internal}` : ''}`; if (line !== last) { log(o.tag ?? TAG, `build ${number}${seen.version ? ` (${seen.version})` : ''}: ${line} [${Math.round((Date.now() - started) / 60_000)} min]`); last = line; } @@ -113,6 +113,17 @@ async function findBuild(c: Asc, appId: string, number: string, marketing?: stri return { ...b, detail }; } +/** Whether the group has a build its testers can install now (past Beta App Review for an external group). An internal + * all-builds group has every build of the app. */ +async function groupHasInstallableBuild(c: Asc, appId: string, g: Group): Promise { + const builds = getsAllBuilds(g) ? await c.all(`/v1/builds?filter[app]=${appId}&limit=200`) : await c.all(`/v1/betaGroups/${g.id}/builds?limit=200`); + for (const b of builds) { + const detail = (await c.maybe(`/v1/builds/${b.id}/buildBetaDetail`))?.data?.attributes ?? null; + if (installableBuild(b, detail, g.internal)) return true; + } + return false; +} + export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; @@ -193,9 +204,35 @@ export async function run(ctx: Ctx): Promise { await execute(c.asc, reqs, { dry, tag: TAG }); if (a.has('invite')) { const fresh = new Map((await c.all(`/v1/betaTesters?filter[apps]=${appId}&limit=200`)).map((t: J) => [String(t.attributes.email ?? '').toLowerCase(), t] as const)); - const pending = want.map(t => fresh.get(t.email)).filter((t): t is J => !!t && ['NOT_INVITED', 'INVITED'].includes(t.attributes.state)); - if (pending.length && await confirm(ctx, `(Re)send the TestFlight invitation email to ${pending.length} tester(s)?`)) - await execute(c.asc, pending.map(t => inviteReq(appId, t.id, maskEmail(String(t.attributes.email)))), { dry: false, tag: TAG }); + // an invitation needs a build the tester can install; without one Apple answers 409 NO_INSTALLABLE_BUILDS (seen live + // on a new external group): add the tester, skip the invite, say when it can go out + const installable = new Map(); + const ready: { t: J; groups: string[] }[] = [], waiting = new Set(); + let waitingTesters = 0; + for (const w of want) { + const t = fresh.get(w.email); + if (!t || !['NOT_INVITED', 'INVITED'].includes(t.attributes.state)) continue; + const own = gs.filter(g => w.groups.includes(g.name)); + for (const g of own) if (!installable.has(g.id)) installable.set(g.id, await groupHasInstallableBuild(c, appId, g)); + if (own.some(g => installable.get(g.id))) ready.push({ t, groups: own.map(g => g.name) }); + else { waitingTesters++; for (const g of own) waiting.add(g.name); } + } + if (waitingTesters) { + const msg = `${waitingTesters} ${noInstallableBuildHint([...waiting])}`; + warn(TAG, msg); + + } + if (ready.length && await confirm(ctx, `(Re)send the TestFlight invitation email to ${ready.length} tester(s)?`)) { + for (const { t, groups } of ready) { + try { await execute(c.asc, [inviteReq(appId, t.id, maskEmail(String(t.attributes.email)))], { dry: false, tag: TAG }); } + catch (e) { + if (!noInstallableBuilds409(e)) throw e; + const msg = `1 ${noInstallableBuildHint(groups)}`; + warn(TAG, msg); + + } + } + } } done(TAG, 'testers done'); return; @@ -282,6 +319,16 @@ export async function run(ctx: Ctx): Promise { if (a.has('submit-review') && review) log(TAG, `beta review: already submitted (${review.attributes?.betaReviewState ?? '?'})`); if (external.length && !a.has('submit-review') && b.detail?.externalBuildState === 'READY_FOR_BETA_SUBMISSION') warn(TAG, 'external testers only get the build after Beta App Review: add --submit-review (asks first) or submit it in App Store Connect'); + // Beta App Review refuses a build whose app has no beta localization in its primary locale (422 + // MISSING_BETA_APP_LOCALIZATION): said before the submit, in the dry run too + if (wantsReview) { + const primary = String((await c.maybe(`/v1/apps/${appId}?fields[apps]=primaryLocale`))?.data?.attributes?.primaryLocale ?? ''); + const have = (await c.all(`/v1/apps/${appId}/betaAppLocalizations?limit=200`)).map((l: J) => String(l.attributes.locale)); + if (primary && !have.includes(primary)) { + const msg = `Beta App Review needs test information in the app's primary locale ${primary} (has: ${have.join(', ') || 'none'}): add it to apple.testflight.description (key ${primary} or its language id), then storebound apple testflight info`; + if (dry) warn(TAG, msg); else throw new Error(msg); + } + } if (dry) { await execute(c.asc, [...reqs, ...(wantsReview ? [betaReviewReq(b.id)] : [])], { dry, tag: TAG }); done(TAG, 'distribute checked (dry run)'); @@ -293,6 +340,12 @@ export async function run(ctx: Ctx): Promise { if (await confirm(ctx, `Submit build ${number} to Beta App Review now? Apple reviews it for external testing (Test Information must be complete: storebound apple testflight info).`)) await execute(c.asc, [betaReviewReq(b.id)], { dry: false, tag: TAG }); } + // testers added to an external group but not invited yet: the invitation needs a build past Beta App Review + if (a.has('submit-review')) { + const waiting: string[] = []; + for (const g of external) if ((await c.all(`/v1/betaGroups/${g.id}/betaTesters?limit=200`)).some((t: J) => t.attributes.state === 'NOT_INVITED')) waiting.push(g.name); + if (waiting.length) { log(TAG, noInstallableBuildHint(waiting)); } + } const added = target.filter(g => reqs.some(r => r.path.includes(`/betaGroups/${g.id}/`))).map(g => g.name); done(TAG, `build ${number}: ${added.length ? `added to ${added.join(', ')}` : `already available to ${target.map(g => g.name).join(', ')}`}`); return; diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 114d989..688bc56 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -1,7 +1,8 @@ // Upload an IPA to App Store Connect and wait until Apple has processed the build. -// storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] +// storebound apple upload [] [--out D] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] // storebound apple upload --platform macos [...] (Mac App Store .pkg from apple build --platform macos --tauri; platform MAC_OS, uti com.apple.pkg; altool gets --type macos; no SDK / privacy-manifest check) -// Default IPA: the newest in /export (and its -/ folders), or with +// Default IPA: the newest in <--out>/export, else the last apple build's /export (.storebound/last-apple-build.json, +// so a build with --out is found), then /export (and its -/ folders), or with // --build N the one with that build number. The bundle id, version and build number come from the IPA's Info.plist. // An IPA whose build number is already on App Store Connect fails with exit 1 (a stale IPA left by a failed // `apple build`, pitfall 53); only --allow-existing (flows that resume after a finished upload) re-checks it instead. Before any network call every PrivacyInfo.xcprivacy in the IPA @@ -27,7 +28,7 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release'; -import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode'; +import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, readLastBuild, remoteBuildSession, showCmd, UPLOAD_DONE, uploadProblems, uploadSearchDirs } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { BUILD_WAIT_EXIT } from '../lib/build-ops'; import { type Asc, ascClient, type J } from './asc'; @@ -50,6 +51,9 @@ function stop(legacy: number, message: string): never { const TAG = 'upload'; +/** Test hook: heartbeat options (short interval, manual ticker) for the reads, the upload and the processing wait. */ +export const hooks: { heartbeat?: HeartbeatOptions } = {}; + function md5File(path: string): string { const h = rt.hasher('md5'); const fd = openSync(path, 'r'); @@ -238,18 +242,32 @@ export async function run(ctx: Ctx): Promise { const macos = parsePlatform(a.get('platform')) === 'MAC_OS'; let ipa = a._.find(x => x.endsWith('.ipa') || x.endsWith('.pkg')); if (!ipa) { - const dir = join(resolve(config.apple?.build?.out ?? 'build/ios'), 'export'); - let found = exportedIpas(dir); - if (want) found = found.filter(f => { try { return ipaInfo(f).build === want; } catch { return false; } }); + const last = readLastBuild(root); + const flagOut = a.get('out'); + const dirs = uploadSearchDirs({ flagOut: flagOut ? resolve(flagOut) : undefined, lastOut: last?.out, configOut: resolve(config.apple?.build?.out ?? 'build/ios') }); + let found: string[] = []; + for (const dir of dirs) { + found = exportedIpas(dir); + if (want) found = found.filter(f => { try { return ipaInfo(f).build === want; } catch { return false; } }); + if (found[0]) break; + } if (!found[0] && macos) throw new Error('usage: storebound apple upload --platform macos (storebound apple build --platform macos --tauri makes one)'); - if (!found[0]) throw new Error(`usage: storebound apple upload (no ${want ? `build ${want} ` : ''}IPA in ${relative(root, dir)}; storebound apple build makes one)`); + if (!found[0]) { + const lastHint = last && !existsSync(last.ipa) ? `; the last apple build (${last.build}) wrote ${last.ipa}, which is gone` : last ? `; the last apple build (${last.build}) is ${last.ipa}` : ''; + throw new Error(`usage: storebound apple upload (no ${want ? `build ${want} ` : ''}IPA in ${dirs.map(d => relative(root, d) || '.').join(', ')}${lastHint}; pass the IPA, --out , or run storebound apple build)`); + } ipa = found[0]; } else ipa = resolve(ipa); shipPhase('verify', `checking ${relative(root, ipa)}`); const isPkg = ipa.endsWith('.pkg'); + log(TAG, `${relative(root, ipa)}: reading ${isPkg ? 'the package (pkgutil)' : 'the IPA'} (${mb(statSync(ipa).size)})`); + // over SSH (no GUI session) anything that touches the login keychain can wait for a prompt nobody sees: say so up front + const remote = process.platform === 'darwin' ? remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim()) : null; + if (remote) warn(TAG, `${remote}: the upload itself uses the App Store Connect API key (no keychain, no prompt), but pkgutil / altool can stall on a keychain or GUI prompt nobody sees here; every step has a timeout. If it stalls, run it in the Mac's logged-in GUI session (see storebound apple build --help, --allow-ssh)`); const platform: 'IOS' | 'MAC_OS' = isPkg ? 'MAC_OS' : 'IOS'; if (macos && !isPkg) throw new Error('--platform macos uploads a signed .pkg (storebound apple build --platform macos --tauri ), not an .ipa'); - const info: ReturnType = isPkg ? pkgInfo(ipa, bundleId) : ipaInfo(ipa); + const info: ReturnType = isPkg ? pkgInfo(ipa, bundleId, { say: m => log(TAG, m) }) : ipaInfo(ipa); + log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build})`); shipReport({ version: info.version, build: info.build, url: consoleUrl('asc.testflight') }); if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`); if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`); @@ -267,18 +285,21 @@ export async function run(ctx: Ctx): Promise { const wait = !a.has('no-wait'); const timeout = parseDuration(a.str('timeout', '60m')); + log(TAG, `asking App Store Connect about ${bundleId}: the app, the editable version, the builds, earlier uploads of build ${info.build}`); const c = await ascClient(root, bundleId, config.apple?.appId); - const appId = await c.app(); - // the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0 - // from a project that ignored the intended version otherwise lands in the wrong train - const editable = await c.editableVersion(platform).catch(() => null); - const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version'); - if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); } - const builds = await c.all(`/v1/builds?filter[app]=${appId}${isPkg ? '&filter[preReleaseVersion.platform]=MAC_OS' : ''}&limit=200&fields[builds]=version,processingState`); + // every read has its own timeout and retries; the heartbeat says the run is alive while one of them waits + const { appId, editable, builds, already } = await withHeartbeat(ms => log(TAG, `still reading App Store Connect, ${elapsed(ms)}`), async () => { + const appId = await c.app(); + // the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0 + // from a project that ignored the intended version otherwise lands in the wrong train + const editable = await c.editableVersion(platform).catch(() => null); + const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`); + // the same IPA uploaded by an earlier run: nothing to send again, only the wait + const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info, platform).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined; + return { appId, editable, builds, already }; + }, hooks.heartbeat); const numbers = builds.map(b => String(b.attributes.version)); const next = nextBuildNumber(numbers); - // the same IPA uploaded by an earlier run: nothing to send again, only the wait - const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info, platform).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined; const onAsc = builds.find(b => String(b.attributes.version) === info.build); const decision = existingBuild({ ipa: relative(root, ipa), build: info.build, onAsc: !!onAsc, state: onAsc?.attributes?.processingState, uploadComplete: !!already, allowExisting: a.has('allow-existing'), next }); // the ship-step contract: a build number App Store Connect has already is "nothing to do" (exit 7), never a second upload @@ -290,14 +311,18 @@ export async function run(ctx: Ctx): Promise { return; } if (decision instanceof Error) throw decision; + log(TAG, `build ${info.build} is not on App Store Connect (next free: ${next})`); if (decision === 'recheck') { log(TAG, `build ${info.version} (${info.build}) is on App Store Connect already${already ? ` (build upload ${already.id})` : ''}; --allow-existing: not sending it again`); if (!wait) { if (json) printJson({ ...{ ipa: relative(root, ipa), version: info.version, build: info.build }, uploaded: true, alreadyUploaded: true }); done(TAG, `build ${info.build} already uploaded`); return; } - const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: timeout, tag: TAG }); + const r = await waitForBuild(c, appId, info.build, { marketing: info.version, platform, timeoutMs: timeout, tag: TAG, ...hooks.heartbeat }); if (json) printJson({ ipa: relative(root, ipa), version: info.version, build: info.build, uploaded: true, alreadyUploaded: true, processingState: r.seen.processingState ?? null }); if (r.outcome === 'valid') { done(TAG, waitMessage(info.build, r)); return; } stop(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout, waitMessage(info.build, r)); } + // (a build number App Store Connect has already ends above, before this: the clearer message) + const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version'); + if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); } if (numbers.length && compareBuild(info.build, next) < 0) warn(TAG, `build ${info.build} is lower than the highest uploaded one (next free: ${next}); Apple accepts it only if this version train has no higher build`); const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform); const plan = via === 'api' @@ -317,14 +342,14 @@ export async function run(ctx: Ctx): Promise { shipPhase('upload', `uploading ${mb(size)} via ${via}`, 0); log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build}), ${mb(size)}, via ${via}, timeout ${a.str('timeout', '60m')}`); if (via === 'api') { - try { await apiUpload(c, appId, ipa, info, deadline, {}, platform); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } + try { await apiUpload(c, appId, ipa, info, deadline, hooks.heartbeat ?? {}, platform); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } else { - try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } + try { await altoolUpload(root, ipa, deadline, hooks.heartbeat); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } if (!wait) { if (json) printJson({ ...summary, uploaded: true }); done(TAG, `build ${info.build} uploaded; storebound apple status shows when it is VALID`); return; } // the same poll as `storebound apple testflight wait`, within what is left of --timeout shipPhase('processing', `waiting for App Store Connect to process build ${info.build}`); - const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG }); + const r = await waitForBuild(c, appId, info.build, { marketing: info.version, platform, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG, ...hooks.heartbeat }); const state = r.timedOut || (r.outcome !== 'valid' && r.outcome !== 'failed') ? 'TIMEOUT' : r.seen.processingState ?? 'UNKNOWN'; if (json) printJson({ ...summary, uploaded: true, processingState: state }); if (r.outcome === 'valid') { done(TAG, `${waitMessage(info.build, r)}. Then: apple testflight distribute --build ${info.build} --group --dry`); return; } diff --git a/cli/apps.ts b/cli/apps.ts index 3260a47..ec0c4fb 100644 --- a/cli/apps.ts +++ b/cli/apps.ts @@ -14,9 +14,10 @@ // desktop shows it read-only ("no local project"); // apps remove apple: | takes it out import type { Args } from './lib/args'; -import { ascKey, playServiceAccount } from './lib/credentials'; -import type { AccountCheck, AppsListResult } from './lib/desktop-protocol'; -import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth'; +import { ascKey } from './lib/credentials'; +import type { AccountCheck, AppsListResult, BrokerGrant } from './lib/desktop-protocol'; +import { PLAY_SCOPE, playCredentials, playToken, REPORTING_SCOPE } from './lib/google-auth'; +import { brokerConfig, brokerPlayInfo, BrokerRefused, BrokerUnavailable, BrokerUnknownAccount, enabledApisOf, grantsOf, type PlayNeed, playGaps } from './lib/broker'; import { errorBody } from './lib/http'; import { done, log, printJson, warn } from './lib/log'; import { appleCheck, type Bytes, dataUrl, type Get, isServiceDisabled, listAppleApps, listPlayApps, localPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps'; @@ -39,8 +40,8 @@ function stores(args: Args, root: string): Store[] { // every store whose credentials are set (env, or .storebound/credentials.json in this folder) const out: Store[] = []; try { ascKey(root); out.push('apple'); } catch { /* not set */ } - try { playServiceAccount(root); out.push('play'); } catch { /* not set */ } - if (!out.length) throw new Error('no store credentials: set ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 and / or PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json)'); + if (playCredentials(root).source) out.push('play'); + if (!out.length) throw new Error('no store credentials: set ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 and / or PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json), or a credential broker (~/.storebound/broker.json)'); return out; } @@ -71,7 +72,7 @@ function googleGet(token: () => Promise, base: string): Get { /** Play icon of one package: an edit is opened, the default language's icon listed, and the edit deleted (never committed). */ function playIcon(root: string): (pkg: string) => Promise { - const token = tokenSource(playServiceAccount(root), PLAY_SCOPE); + const token = playToken(root, PLAY_SCOPE); const API = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/'; const call = async (method: string, url: string) => { const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()) } }); @@ -96,7 +97,7 @@ function playIcon(root: string): (pkg: string) => Promise { }; } -const principalOf = (root: string): string | null => { try { return playServiceAccount(root).client_email; } catch { return null; } }; +const principalOf = (root: string): string | null => playCredentials(root).principal; export async function listApps(store: Store, root: string, icons: boolean): Promise { const at = new Date().toISOString(); @@ -104,15 +105,15 @@ export async function listApps(store: Store, root: string, icons: boolean): Prom const r = await listAppleApps(await appleGet(root), { icons, bytes }); return { store: 'ios', ...r, at }; } - const sa = playServiceAccount(root); + const token = playToken(root, REPORTING_SCOPE); try { - const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API), icons ? { icon: playIcon(root) } : {}); + const r = await listPlayApps(googleGet(token, REPORTING_API), icons ? { icon: playIcon(root) } : {}); return { store: 'android', ...r, at }; } catch (e) { // the Reporting API is off in the service account's Cloud project: a clear finding with the exact link, and the // Play apps this machine knows (app folders' configs, store-only entries) instead of failing the whole list if (!isServiceDisabled(e)) throw e; - const c = playCheck(sa.client_email, { error: e }); + const c = playCheck(token.principal(), { error: e }); const known = knownPlayPackages(root); return { store: 'android', apps: localPlayApps(known), at, warnings: [`${c.message} Fix: ${c.fix}`, known.length ? `listed ${known.length} Play app(s) from this machine's app folders and store-only entries instead (not read from Google Play)` : 'no Play app folders on this machine to list instead (storebound apps add)'] }; @@ -152,12 +153,56 @@ export async function checkAccount(store: Store, root: string): Promise | null = null; + try { reporting = playToken(root, REPORTING_SCOPE); } catch (e) { if (source !== 'broker') throw e; } + if (reporting) apps = ((await googleGet(reporting, REPORTING_API)('/apps:search?pageSize=1000')).apps ?? []).length; + const c = apps === null + ? { store: 'android' as const, status: 'ok' as const, principal, apps: null, message: `The credential broker hands out Play tokens for ${principal ?? 'the service account'} (the app count needs the Reporting API scope, which the broker does not hand out).`, fix: null, docs: null } + : playCheck(principal, { apps }); + return { ...c, source, ...(await brokerGrants(root, principal)) }; + } catch (e) { + if (e instanceof BrokerRefused || e instanceof BrokerUnavailable || e instanceof BrokerUnknownAccount) + return { store: 'android', status: 'error', principal, apps: null, source, message: (e as Error).message, fix: 'Check the credential broker: ~/.storebound/broker.json (url, apiKeyFile) and the scopes of the API key (google:read, google:token).', docs: null }; + return { ...playCheck(principal, { error: e }), source }; + } +} + +const ALL_NEEDS: PlayNeed[] = ['upload', 'listing', 'iap', 'declarations', 'reviews']; + +/** With a credential broker: the Play permissions and enabled APIs it lists for the Play apps this machine knows (at + * most 25, 4 reads at a time), for the account check (CLI and the desktop's Accounts view). */ +async function brokerGrants(root: string, principal: string | null): Promise<{ grants?: BrokerGrant[] }> { + let cfg; + try { cfg = brokerConfig(root); } catch { return {}; } + if (!cfg) return {}; + const pkgs = knownPlayPackages(root).slice(0, 25).map(k => k.packageName); + const out: (BrokerGrant | null)[] = new Array(pkgs.length).fill(null); + let next = 0; + await Promise.all(Array.from({ length: Math.min(4, pkgs.length) }, async () => { + while (next < pkgs.length) { + const i = next++, pkg = pkgs[i]!; + const r = await brokerPlayInfo(cfg, pkg); + if (r.kind === 'ok') { + if (principal && r.info.account && r.info.account !== principal) continue; + out[i] = { packageName: pkg, registered: true, grants: grantsOf(r.info), enabledApis: enabledApisOf(r.info), missing: playGaps(r.info, ALL_NEEDS).map(g => g.name) }; + } else if (r.kind === 'not-registered') out[i] = { packageName: pkg, registered: false, grants: null, enabledApis: null, missing: [] }; + } + })); + return { grants: out.filter((g): g is BrokerGrant => !!g) }; } const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p); diff --git a/cli/check.ts b/cli/check.ts index 89ddd21..5311034 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -16,7 +16,7 @@ import { type BundleManifest, bundleTrackingIssues, ipaManifests, projectSdkMani import { trackingIssue } from './lib/privacy-manifest'; import { type AsoFinding, auditAso } from './lib/aso'; import { draftLocales } from './lib/aso-intel'; -import { appleName, playCodes } from './lib/config'; +import { appleName, listingPathFor, playCodes } from './lib/config'; import { fieldsMentions } from './lib/cross-store'; import { appBuildFile, pickGradle, versionSourceIssues, versionSourceMessage } from './lib/gradle'; import { playConsoleReminders } from './lib/console-plan'; @@ -38,6 +38,7 @@ import { iphoneDuoNote } from './shots/spec'; import { hitLine } from './lib/cross-store'; import { findFastlaneTraces, traceMessage } from './lib/fastlane-traces'; import { listingFileMentions } from './lib/listing-mentions'; +import { projectFamilies } from './lib/device-families'; /** The newest .ipa in a folder (apple build's export), or null. */ function newestIpa(dir: string): string | null { @@ -106,6 +107,12 @@ export async function run(ctx: Ctx): Promise { try { const tr = findFastlaneTraces(dirname(path)); if (tr.length) for (const t of tr) note(traceMessage(t)); } catch (e) { warn('check', `fastlane traces: ${(e as Error).message}`); } // other platforms named in a store's text: App Store 2.3.10 / Play Metadata, a blocker (pitfall 77) try { for (const h of listingFileMentions(ctx.loaded)) problems.push(`other platform in ${hitLine(h)}`); } catch (e) { warn('check', `platform mentions: ${(e as Error).message}`); } + if (config.apple?.macos?.listing) tryDo('appstore listing (macOS)', () => { + const copy = parseAppStoreListing(readFileSync(resolve(listingPathFor(config, 'macos')), 'utf8')); + const missing = config.languages.filter(l => l.apple && !copy[l.apple]).map(l => l.apple); + if (missing.length) note(`macOS appstore listing has no section for ${missing.join(', ')}`); + return `${Object.keys(copy).length} locales`; + }); // listing translation drafts (storebound listing translate): fine to keep working on, not to push unreviewed for (const f of [config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : '', config.play ? resolve(config.play.listing ?? 'store/listing.md') : ''].filter(Boolean)) { if (!existsSync(f)) continue; @@ -173,6 +180,14 @@ export async function run(ctx: Ctx): Promise { note('shots.capture.auth signs in to a local stack with reviewDemoAccount, the account App Review gets from apple review-details: put the local demo sign-in in captureAuth in .storebound/credentials.json and use ${cred:}'); } const duo = iphoneDuoNote({ apple: !!config.apple, duo: !!config.apple?.iphoneDuo, play: !!config.play }); if (duo?.level === 'info') { hints.push(duo.msg); log('check', `hint: ${duo.msg}`); } else if (duo) note(duo.msg); + // device families: offline only the project and the config can be compared; the live version's families need the store + if (config.apple) { + const proj = projectFamilies(ctx.loaded.root, config.apple.bundleId, config.apple.build?.project); + const iosOnly = !proj || !proj.families.ipad; + if (proj && proj.families.ipad && config.apple.ipad !== true) note(`${proj.file} builds for iPad (TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')}) but apple.ipad is not true: iPad screenshots are not required or checked (set apple.ipad: true)`); + else if (proj && !proj.families.ipad && config.apple.ipad === true) note(`apple.ipad is true but ${proj.file} builds TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')} (no iPad): set "1,2" or apple.ipad: false`); + else if (iosOnly && config.apple.ipad !== true) note(`device families: ${proj ? `iPhone only (apple.ipad not true, TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')})` : 'no Xcode project found here'}, and the families of the live version are not known offline: if it supports iPad, Apple refuses an iPhone-only upload after the build (ITMS-90101). storebound validate and apple build compare with the store first`); + } const creative = creativeNote(config, ctx.loaded.resolve(config.renders ?? config.shots?.out ?? 'store/renders')); if (creative) { hints.push(creative); log('check', `hint: ${creative}`); } // the scaffolder's own placeholders would reach the stores as the app's copyright, contact and legal pages diff --git a/cli/commands.ts b/cli/commands.ts index 01665de..3cba163 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -10,7 +10,7 @@ export const PLAIN: Record Promise, string]> = { 'init': [() => import('./init'), 'copy the templates (config, listing, copy, declarations, privacy policy) into this project; --profile app|game|subscription-app (default app)'], 'import': [() => import('./import'), 'fastlane [--dir fastlane] [--profile app|game|subscription-app] [--dry]: Appfile, deliver + supply metadata and screenshots -> config, store/*.md, shots frames (read-only on fastlane, never overwrites)'], 'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'], - 'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'], + 'mcp': [() => import('./mcp-cmd'), 'MCP server wrapping these commands (for any MCP-capable AI tool): stdio, or --http [--port 7787] [--roots ] [--auth token|introspect] for one server every session shares'], 'watch': [() => import('./watch'), 'store events for every app on this machine: rejected, approved, live, rollout halted, crash threshold, low reviews; loops every --every 15m or --once; sinks --notify os|ntfy|telegram|webhook|ingest; test sends a test event; --dry, --dir , --json (read-only on the stores)'], 'status': [() => import('./status'), '[--app ] [--json]: per platform the live and next version, build, track, review state and rollout %, without uploading (read-only; read by ship pipelines, docs/ship-step.md)'], 'events': [() => import('./events'), '[--since 24h] [--app ] [--kind rejected] [--json]: the events watch recorded in ~/.storebound/events.jsonl (read-only)'], @@ -35,12 +35,12 @@ export const COMMANDS: Record Promise, string]> = { 'check': [() => import('./check'), 'validate config, copy files, data safety answers, products, subscriptions and the app profile (offline); --aso keyword audit'], 'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'], 'report': [() => import('./report'), 'shareable store readiness report: scan + check + validate (read-only; only the stores with credentials, else it says why) in one self-contained HTML page .storebound/reports/readiness-.html, opened in the browser; --out , --md, --json, --no-open, --offline'], - 'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'], + 'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, App Usage, conversion -> .storebound/analytics; --json prints it) | compare (vs listing changes in git)'], 'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'], 'art': [() => import('./art'), 'pull [--store apple|play] [--write] [--force]: the live store art (App Store screenshot sets, previews, icon; Play icon, feature graphic, screenshots per language) into store/renders + pulled.json, so checks and uploads treat it like rendered art (upsert; differing local files kept without --force; read-only on the stores) | live [--json]: the live art with thumbnails | replace --from --to : one image of the app folder'], 'onboard': [() => import('./onboard'), 'an app that is already live: the live products + subscriptions with their USD / base prices, contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page, from the stores, the code and the app\'s site -> config and .storebound/credentials.json (--write; read-only on the stores, never changes store prices); then the owner questions nothing proves'], 'catalog': [() => import('./catalog'), 'diff: cross-store parity of products + subscriptions (missing / extra / mismatched, prices per region, offers); read-only, exit 1 on mismatches | pull [--write]: store texts of IAPs + subscriptions as a config proposal'], - 'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; read-only'], + 'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; apple-sales and play-sales also --days N | --from/--to (per-day JSON); read-only'], 'triage': [() => import('./triage'), 'TestFlight crash / screenshot feedback and Play error reports grouped by signature, device, OS version, build; read-only, --json'], 'aso': [() => import('./aso'), 'rank | competitors | suggest | audit: App Store ranks, top apps per keyword, keyword field proposals (Apple Ads popularity with credentials) and one ASO report, from the public iTunes Search API -> .storebound/aso/'], 'prices': [() => import('./prices'), 'export | import a CSV of product x store x region prices (diff first, asks before writing; Apple future-dated changes) | ppp --base (purchasing power price sheet, snapped to store price points)'], @@ -59,9 +59,9 @@ export const COMMANDS: Record Promise, string]> = { 'play listing': [() => import('./play/listing'), 'app details + title / short / full description per language; pull [--write]: live listings back into the listing file (diff first)'], 'play images': [() => import('./play/images'), 'icon, feature graphic, phone screenshots per language, optional tablet / TV / Wear sets when rendered (play.tablets); --variant , --prune'], 'play data-safety': [() => import('./play/data-safety'), '[--force] Data safety form (CSV import through the API; skipped when unchanged since the last upload)'], - 'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes'], + 'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes; --production completed|inProgress --yes ships an UPDATE of a published app without an owner step (a first-ever production release is the owner\'s: exit 3 OWNER_STEP, --first-release after the owner\'s yes)'], 'play build': [() => import('./play/build'), 'gradle bundleRelease (Flutter: flutter build appbundle) with the next free versionCode and the upload key from env / .storebound; --dry'], - 'play upload': [() => import('./play/upload'), 'upload an AAB to a testing track (--track internal|alpha|beta), versionCode checked first; --dry read-only, asks first'], + 'play upload': [() => import('./play/upload'), 'upload an AAB to a track (--track internal|alpha|beta|production), versionCode checked first; production with --yes is an update step for a published app (a first-ever release is the owner\'s: exit 3 OWNER_STEP); --dry read-only, asks first'], 'play status': [() => import('./play/status'), 'tracks, listings, products (read-only)'], 'play rollout': [() => import('./play/rollout'), 'staged rollout: --fraction x | --halt | --resume | --complete (asks first)'], 'play share': [() => import('./play/share'), 'internal app sharing: upload an AAB / APK, print the share link (asks first; --dry)'], @@ -74,7 +74,7 @@ export const COMMANDS: Record Promise, string]> = { 'apple subscriptions': [() => import('./apple/subscriptions'), 'subscription groups, subscriptions, localizations, prices (equalized + overrides + --csv), intro offers, availability, review screenshot (asks first)'], 'apple iap-review': [() => import('./apple/iap-review'), 'IAP review screenshots + product states'], 'apple iap-submit': [() => import('./apple/iap-submit'), 'IAP + subscription versions into a review submission (first of a type: --with-version), then submit (asks first; --no-submit keeps a draft); reuses the open submission, never a second one'], - 'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], + 'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes; an UPDATE of an app on sale needs no owner step, the first-ever submission is exit 3 OWNER_STEP unless --first-release); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple resubmit': [() => import('./apple/resubmit'), 'after a rejection: --build attached to the rejected version, the UNRESOLVED_ISSUES submission reused (missing items added), resubmitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple offer-codes': [() => import('./apple/offer-codes'), 'offer codes for one-time purchases (campaigns; custom and one-time-use codes with --codes, asks first)'], 'apple win-back': [() => import('./apple/win-back'), 'win-back offers for lapsed subscribers (asks first: they go live on their start date)'], @@ -85,11 +85,11 @@ export const COMMANDS: Record Promise, string]> = { 'apple listing': [() => import('./apple/listing'), 'version, build, name / subtitle / description / keywords per locale; pull [--write]: live texts back into the listing file (diff first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" (+ 13" iPad) screenshots per locale; --variant ; --repair-stuck [--dry] (replace only screenshots not COMPLETE, at their positions); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'], - 'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'], + 'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes; --platform ios|macos|tvos|visionos (macos: apple.macos.reviewNotes when set)'], 'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission ); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and places it once Apple approved it, reporting waiting ones per locale (MD5 upsert, safe to re-run); --add-for-review, --verify (read-only), --dry'], 'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'], - 'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], + 'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --after-approval | --manual | --scheduled (how an unapproved version goes live; exit 7 when already that type), --manual-release (asks first; --yes for an update of an app on sale, exit 7 when already released; the first-ever release is exit 3 OWNER_STEP unless --first-release); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'], 'apple signing': [() => import('./apple/signing'), 'status | ensure: bundle id capabilities, distribution certificate (key kept in .storebound/signing), App Store profiles installed for Xcode; --dry, asks first'], 'apple build': [() => import('./apple/build'), 'xcodebuild archive + exportArchive (ExportOptions.plist generated, next build number, API key signing); Capacitor, React Native, Flutter, Unity; --platform macos --tauri [--path-free] builds a Tauri app for the Mac App Store (signed .app, sandbox entitlements, profile, productbuild .pkg); --dry'], 'apple upload': [() => import('./apple/upload'), 'upload an IPA, or with --platform macos a signed .pkg (App Store Connect API build upload, or --via altool), build number checked, waits for processing; --dry, asks first'], diff --git a/cli/console/plan.ts b/cli/console/plan.ts index be96c28..2c68c40 100644 --- a/cli/console/plan.ts +++ b/cli/console/plan.ts @@ -2,6 +2,7 @@ // storebound console plan lists the forms // storebound console plan
[--json] [--lookup] // storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list +// storebound console plan apple-app-group [--bundle-id ] [--groups group.a,group.b] [--lookup] [--json] the developer portal App Group assignment the API cannot do // Read-only. Offline by default: the start URL, the ordered steps, the answer sheet (each answer certain or "ask // owner", with the console's own labels), the never-press list, the known UI facts and how to verify. Deep links use // apple.appId / play.developerId / play.appId, the ids cached by earlier runs (.storebound/console-app-ids.json, @@ -12,8 +13,31 @@ import type { Ctx } from '../lib/ctx'; 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 type { ShipConfig } from '../lib/config'; import { printJson } from '../lib/log'; +/** apple-app-group: the targets whose entitlements (or apple.signing.extensions) list App Groups, narrowed by --bundle-id; + * --groups g1,g2 names the groups of a bundle id the project does not show; --lookup reads the bundle id's resource id (a GET). */ +async function appGroupTargets(ctx: Ctx, cfg: ShipConfig, bundleId: string | undefined, groups: string | undefined, lookup: boolean): Promise> { + const { discoverTargets } = await import('../apple/signing'); + const scan = discoverTargets(ctx.loaded.root, cfg); + const all = [...(scan.main ? [scan.main] : []), ...scan.extensions]; + const listed = groups ? groups.split(',').map(g => g.trim()).filter(Boolean) : []; + for (const g of listed) if (!/^group.[A-Za-z0-9.-]+$/.test(g)) throw new Error(`console plan apple-app-group: --groups "${g}" is not an App Group id (group.)`); + let found = all.filter(t => t.appGroups.length && (!bundleId || t.bundleId === bundleId)).map(t => ({ bundleId: t.bundleId, name: t.name, groups: listed.length ? listed : t.appGroups, source: t.entitlements ? `entitlements ${t.entitlements}` : 'apple.signing.extensions' })); + if (bundleId && !found.length) { + if (!listed.length) throw new Error(`console plan apple-app-group: no target "${bundleId}" with App Groups in the project (targets with groups: ${all.filter(t => t.appGroups.length).map(t => t.bundleId).join(', ') || 'none'}); pass --groups group.a,group.b`); + found = [{ bundleId, name: bundleId, groups: listed, source: '--groups' }]; + } + if (!lookup || !found.length) return found; + const bundle = cfg.apple?.bundleId; + if (!bundle) throw new Error('console plan: apple.bundleId is not set (needed for --lookup)'); + const { ascClient } = await import('../apple/asc'); + const { findBundleId } = await import('../apple/signing'); + const c = await ascClient(ctx.loaded.root, bundle, cfg.apple?.appId); + return Promise.all(found.map(async t => ({ ...t, resourceId: (await findBundleId(c, t.bundleId))?.id ?? null }))); +} + export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const args = ctx.args, json = args.has('json'); @@ -21,7 +45,7 @@ export async function run(ctx: Ctx): Promise { if (!form) { 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 }; + return { id, store: id.startsWith('apple-') ? 'apple' as const : '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'); @@ -53,6 +77,7 @@ export async function run(ctx: Ctx): Promise { ids.readyChanges = await readyFromApi(root, cfg); } catch { /* not read: the plan says so */ } } + if (form === 'apple-app-group') ids.appGroupTargets = await appGroupTargets(ctx, cfg, args.get('bundle-id'), args.get('groups'), args.has('lookup')); if (args.has('all') && form !== 'play-app-content') throw new Error('console plan: --all is for play-app-content (every App content card inline)'); const plan = consolePlan(form, cfg, ids, { all: args.has('all') }); if (json) return printJson(plan); diff --git a/cli/console/verify.ts b/cli/console/verify.ts index 5310af6..f513920 100644 --- a/cli/console/verify.ts +++ b/cli/console/verify.ts @@ -2,6 +2,7 @@ // storebound console verify [--from ] [--from-text ] [--json] // --from takes the JSON of the plan's verify.capture snippet (input states: aria-checked, checked, aria-selected) or page text. // storebound console verify play-app-record --url [--write] +// storebound console verify apple-app-group [--bundle-id ] (API: the App Groups on the App ID's newest readable profile) // 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. play-app-content: the plan's @@ -43,6 +44,21 @@ export async function run(ctx: Ctx): Promise { const { readyFromApi } = await import('../play/publish-ready'); const { verifyPublishChangesApi } = await import('../lib/console-verify'); r = verifyPublishChangesApi(await readyFromApi(root, config)); + } else if (form === 'apple-app-group') { + const { discoverTargets, readGroupEvidence } = await import('../apple/signing'); + const { appGroupVerdicts } = await import('../lib/targets'); + const { verifyAppGroupsApi } = await import('../lib/console-verify'); + const want = args.get('bundle-id'); + const scan = discoverTargets(root, config); + const targets = [...(scan.main ? [scan.main] : []), ...scan.extensions].filter(t => t.appGroups.length && (!want || t.bundleId === want)); + if (!targets.length) throw new Error(`console verify apple-app-group: no target${want ? ` "${want}"` : ''} with App Groups in the project's entitlements or apple.signing.extensions`); + const bundleId = config.apple?.bundleId; + if (!bundleId) throw new Error('console verify: apple.bundleId is not set'); + const { ascClient } = await import('../apple/asc'); + const c = await ascClient(root, bundleId, config.apple?.appId); + const style = config.apple?.signing?.style ?? 'automatic'; + const evidence = await readGroupEvidence(c, targets, style, config.apple?.signing?.profileType ?? 'IOS_APP_STORE'); + r = verifyAppGroupsApi(targets, appGroupVerdicts(targets, evidence)); } else if (form === 'apple-app-record' || form === 'apple-age-rating') { const bundleId = config.apple?.bundleId; if (!bundleId) throw new Error('console verify: apple.bundleId is not set'); diff --git a/cli/data/flags.json b/cli/data/flags.json index 7473ef4..fcb31d3 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -38,6 +38,7 @@ "days", "dir", "md", + "no-write", "paths", "store" ], @@ -66,22 +67,31 @@ "allow-price-changes" ], "apple build": [ + "allow-app-group-gap", + "allow-device-gap", + "allow-entitlements", "allow-ssh", "allow-stale-web", "allow-version-mismatch", "build-number", "configuration", + "derived-data", + "entitlements", "env-file", + "evidence", "keychain", + "keychain-unlock", "locale-map", "no-install", "no-prebuild", "out", "path-free", "platform", + "pre-check", "profile", "progress", "project", + "refresh-profile", "result-file", "scan-paths", "scheme", @@ -96,6 +106,7 @@ "name" ], "apple cpp": [ + "duo-only", "include-drafts", "jobs", "name", @@ -122,7 +133,8 @@ "apple game-center": [ "keychain", "locale-map", - "no-install" + "no-install", + "refresh-profile" ], "apple iap": [ "all", @@ -193,6 +205,7 @@ "write" ], "apple ppo": [ + "duo-only", "experiment", "include-drafts", "jobs", @@ -223,14 +236,23 @@ "enable" ], "apple release": [ + "after-approval", + "env-file", + "first-release", + "manual", "manual-release", "phased", "platform", - "version" + "progress", + "result-file", + "scheduled", + "version", + "version-from" ], "apple resubmit": [ "build", "env-file", + "first-release", "keychain", "locale-map", "new-version", @@ -238,6 +260,7 @@ "no-submit", "platform", "progress", + "refresh-profile", "resolve-iaps", "result-file", "version", @@ -245,9 +268,11 @@ "with-version" ], "apple review-details": [ - "contact-from" + "contact-from", + "platform" ], "apple screenshots": [ + "duo-only", "include-drafts", "jobs", "not-sent-for-review", @@ -261,7 +286,8 @@ "apple signing": [ "keychain", "locale-map", - "no-install" + "no-install", + "refresh-profile" ], "apple status": [ "cancel-submission", @@ -269,6 +295,7 @@ "locale-map", "no-install", "platform", + "refresh-profile", "timeout", "version", "wait-review" @@ -276,6 +303,7 @@ "apple submit": [ "build", "env-file", + "first-release", "keychain", "locale-map", "new-version", @@ -283,6 +311,7 @@ "no-submit", "platform", "progress", + "refresh-profile", "resolve-iaps", "result-file", "version", @@ -345,6 +374,7 @@ "locale", "no-wait", "older-than", + "out", "platform", "pre-check", "progress", @@ -460,6 +490,7 @@ "name", "no-install", "react-native", + "refresh-profile", "sku", "storefront", "unity" @@ -490,6 +521,7 @@ "all", "allow-availability-changes", "allow-price-changes", + "bundle-id", "capacitor", "cdp", "chrome-profile", @@ -501,10 +533,15 @@ "flutter", "fresh-profile", "godot", + "groups", + "keychain", + "locale-map", "lookup", "name", + "no-install", "profile", "react-native", + "refresh-profile", "sku", "start-url", "step-timeout", @@ -515,6 +552,7 @@ "console verify": [ "allow-availability-changes", "allow-price-changes", + "bundle-id", "capacitor", "cordova", "cordova-android", @@ -524,8 +562,12 @@ "from", "from-text", "godot", + "keychain", + "locale-map", "name", + "no-install", "react-native", + "refresh-profile", "sku", "text", "unity", @@ -556,6 +598,7 @@ "since" ], "flow": [ + "apple", "apply", "from", "resume" @@ -563,7 +606,8 @@ "games status": [ "keychain", "locale-map", - "no-install" + "no-install", + "refresh-profile" ], "health": [ "apple", @@ -610,7 +654,26 @@ "to", "translate" ], - "mcp": [], + "mcp": [ + "allow-origin", + "allow-remote-token", + "auth", + "broker-profiles", + "host", + "http", + "introspect-key-file", + "introspect-url", + "issuer", + "port", + "project", + "public", + "rate", + "read-only", + "resource", + "roots", + "token-file", + "trust-proxy" + ], "onboard": [ "accounts", "all", @@ -736,6 +799,7 @@ "alpha", "beta", "env-file", + "first-release", "fraction", "include-drafts", "internal", @@ -799,6 +863,7 @@ "play upload": [ "env-file", "evidence", + "first-release", "include-drafts", "name", "not-sent-for-review", @@ -887,11 +952,14 @@ "reports": [ "csv", "date", + "days", "detail", "frequency", + "from", "month", "out", "region", + "to", "type", "vendor" ], @@ -1048,6 +1116,7 @@ "cordova-android", "cordova-ios", "days", + "duo-only", "expo", "flutter", "godot", @@ -1067,6 +1136,7 @@ "play", "prune", "react-native", + "refresh-profile", "repair-stuck", "signature", "skip-out-of-range-regions", @@ -1109,13 +1179,19 @@ "accounts", "add-for-review", "adgroup", + "after-approval", "against", "agent", "all", "all-pages", + "allow-app-group-gap", "allow-availability-changes", + "allow-device-gap", + "allow-entitlements", "allow-existing", + "allow-origin", "allow-price-changes", + "allow-remote-token", "allow-ssh", "allow-stale-web", "allow-version-mismatch", @@ -1128,11 +1204,13 @@ "apps", "archive", "aso", + "auth", "base", "base-url", "beta", "bid", "body", + "broker-profiles", "build", "build-number", "bundle-id", @@ -1165,12 +1243,15 @@ "debug-dump", "declarations", "delay", + "derived-data", "detail", "device", "dir", "download", + "duo-only", "enable", "end", + "entitlements", "env-file", "every", "evidence", @@ -1178,6 +1259,7 @@ "expo", "fields", "file", + "first-release", "flavor", "floor", "flutter", @@ -1192,10 +1274,13 @@ "from-text", "godot", "group", + "groups", "halt", "headless", "home", "hook", + "host", + "http", "icon", "icons", "identity", @@ -1204,14 +1289,18 @@ "install", "internal", "internal-only", + "introspect-key-file", + "introspect-url", "invite", "ios", "issue", + "issuer", "issues", "jobs", "keep", "keep-booted", "keychain", + "keychain-unlock", "keyword", "keywords", "kind", @@ -1224,6 +1313,7 @@ "locale", "locale-map", "lookup", + "manual", "manual-release", "max-level", "max-ratio", @@ -1257,6 +1347,7 @@ "no-variants", "no-wait", "no-web", + "no-write", "not-sent-for-review", "notify", "off", @@ -1288,10 +1379,14 @@ "propose", "prune", "prune-languages", + "public", "q", + "rate", "rating", "react-native", + "read-only", "reconvert", + "refresh-profile", "region", "release", "release-date", @@ -1299,12 +1394,15 @@ "render", "repair-stuck", "resolve-iaps", + "resource", "result-file", "resume", "review-sent", "root", + "roots", "scan-paths", "scenes", + "scheduled", "scheme", "sdk-dirs", "search", @@ -1335,11 +1433,13 @@ "threshold", "timeout", "to", + "token-file", "top", "track", "tracking-domains", "traffic", "translate", + "trust-proxy", "type", "udid", "unanswered", @@ -1395,7 +1495,7 @@ ], "analytics": [ "storebound analytics setup [--dry] App Store: create the ONGOING analytics report request (Admin key; once)", - "storebound analytics snapshot [--days 28] [--store apple|play] [--dry] -> .storebound/analytics/.json", + "storebound analytics snapshot [--days 28] [--store apple|play] [--dry] [--json] [--no-write] -> .storebound/analytics/.json", "storebound analytics compare [--days 7] [--paths store,storebound.config.ts] [--md]" ], "api": [ @@ -1415,7 +1515,7 @@ "storebound apple appinfo [--dry] [--json] [--allow-price-changes] [--allow-availability-changes]" ], "apple build": [ - "storebound apple build --platform macos --tauri [--path-free] [--unsigned] [--target T] [--profile F] [--version x.y.z] [--build-number N] [--out D]", + "storebound apple build --platform macos --tauri [--path-free] [--unsigned] [--target T] [--profile F] [--entitlements ] [--allow-entitlements] [--version x.y.z] [--build-number N] [--out D]", "storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]" ], "apple bundle": [ @@ -1483,21 +1583,22 @@ ], "apple release": [ "storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]", - "storebound apple release --manual-release [--version 1.2.0] [--yes]" + "storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json]", + "storebound apple release --after-approval | --manual | --scheduled [--version 1.2.0] [--first-release] [--yes] [--json]" ], "apple resubmit": [ - "storebound apple resubmit --build [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]" + "storebound apple resubmit --build [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]" ], "apple review-details": [ - "storebound apple review-details [--contact-from ] [--dry]" + "storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from ] [--dry]" ], "apple screenshots": [ - "storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]", + "storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry]", "storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)" ], "apple signing": [ "storebound apple signing status [--json] read-only: capabilities, certificates (with / without a", - "storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]" + "storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json]" ], "apple status": [ "storebound apple status [--platform ios|macos|tvos|visionos] [--json]", @@ -1522,7 +1623,7 @@ "storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json]" ], "apple upload": [ - "storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]", + "storebound apple upload [] [--out D] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]", "storebound apple upload --platform macos [...] (Mac App Store .pkg from apple build --platform macos --tauri; platform MAC_OS, uti com.apple.pkg; altool gets --type macos; no SDK / privacy-manifest check)" ], "apple win-back": [ @@ -1564,11 +1665,13 @@ "console plan": [ "storebound console plan lists the forms", "storebound console plan [--json] [--lookup]", - "storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list" + "storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list", + "storebound console plan apple-app-group [--bundle-id ] [--groups group.a,group.b] [--lookup] [--json] the developer portal App Group assignment the API cannot do" ], "console verify": [ "storebound console verify [--from ] [--from-text ] [--json]", - "storebound console verify play-app-record --url [--write]" + "storebound console verify play-app-record --url [--write]", + "storebound console verify apple-app-group [--bundle-id ] (API: the App Groups on the App ID's newest readable profile)" ], "desktop": [ "storebound desktop the app has this CLI's version: open it (in an app folder: on that folder, which", @@ -1655,7 +1758,7 @@ "storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes]" ], "play release": [ - "storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--dry]", + "storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--first-release] [--dry]", "storebound play release --version-code 3 --production draft", "storebound play release app.aab --production completed --not-sent-for-review (commit with changesNotSentForReview=true)" ], @@ -1672,7 +1775,7 @@ "storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]" ], "play upload": [ - "storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]" + "storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--first-release] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]" ], "prices": [ "storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]", @@ -1690,7 +1793,9 @@ "storebound reports apple-sales [--type SALES] [--frequency DAILY|WEEKLY|MONTHLY|YEARLY] [--date ...] [--vendor N]", "storebound reports apple-finance --month YYYY-MM [--detail] [--region ZZ] [--vendor N]", "storebound reports play-earnings --month YYYY-MM", - "storebound reports play-sales --month YYYY-MM" + "storebound reports play-sales --month YYYY-MM", + "storebound reports apple-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (daily SALES reports)", + "storebound reports play-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (per order-charged date and currency)" ], "revenuecat": [ "storebound revenuecat diff [--json] read-only parity report; exit 1 when out of sync", @@ -1715,7 +1820,7 @@ "storebound settings bypass on | off | status owner opt-in: the agent also presses the owner-only buttons" ], "shots capture": [ - "storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder]" + "storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet|landscape] [--source web|ios-sim|android-emu|folder]" ], "shots check": [ "storebound shots check [--lang en,tr] [--variants] [--json]" diff --git a/cli/doctor.ts b/cli/doctor.ts index 612d04f..4cf63f8 100644 --- a/cli/doctor.ts +++ b/cli/doctor.ts @@ -4,12 +4,14 @@ // storebound doctor [--dir .] [--json] // storebound doctor --install [--yes] runs this OS's install command for one tool (winget / brew / apt); // without --yes only prints it. Never sudo, never an admin prompt. -import { existsSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import pkg from '../package.json' with { type: 'json' }; import type { Args } from './lib/args'; import { CONFIG_NAMES } from './lib/config'; -import { credentialStatus } from './lib/credentials'; +import { credentialStatus, maskUser } from './lib/credentials'; +import { type PlayCredentials, playCredentials } from './lib/google-auth'; import { log, printJson } from './lib/log'; import { textTable } from './lib/table'; import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools'; @@ -38,6 +40,9 @@ export interface DoctorResult { /** Store credentials of the project folder (env, .storebound/credentials.json, profile): set, missing or * placeholder (a template value, treated as missing). Never the values. */ credentials: { key: string; status: 'set' | 'missing' | 'placeholder' }[]; + /** Where Play tokens come from: the credential broker (~/.storebound/broker.json) or the local key file; the + * e-mail masked, never a key. */ + play: PlayCredentials; /** macOS: a non-Apple rsync first on PATH (Homebrew 3.x breaks Xcode's exportArchive, pitfall 54), else null. */ foreignRsync: string | null; } @@ -50,10 +55,24 @@ export function doctor(cwd: string, env: Record = pr usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' }); const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null; const sdk = androidSdk([join(cwd, 'android'), cwd], env); - return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd), + return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd), play: maskedPlay(cwd), foreignRsync: process.platform === 'darwin' ? foreignRsync(env.PATH) : null }; } +function maskedPlay(cwd: string): PlayCredentials { + const p = playCredentials(cwd); + return { ...p, principal: p.principal ? maskUser(p.principal) : null }; +} + +/** The one line about Play access: which source, and the broker's state. */ +export function playLine(p: PlayCredentials): string { + const who = p.principal ? ` for ${p.principal}` : ''; + if (p.source === 'broker') return `Play: tokens from the credential broker ${p.broker!.url}${who}${p.local ? ' (local key as fallback)' : ''}`; + const broken = p.broker?.error ? ` · credential broker not usable: ${p.broker.error}` : ''; + if (p.source === 'file') return `Play: local service account key${who}${broken}`; + return `Play: no service account key and no credential broker${broken}`; +} + /** One install command of a tool, run only with --yes; sudo / admin commands are printed, never run. */ export function installPlan(r: DoctorResult, id: string, platform: NodeJS.Platform = process.platform): { tool: DoctorRow; command: string | null; runnable: boolean } { const tool = r.tools.find(t => t.id === id); @@ -95,6 +114,9 @@ export async function run(args: Args): Promise { : `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`); const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key); log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`); + log('doctor', playLine(r.play)); + const tcc = protectedInstall(realScript(), homedir()); + if (tcc) log('doctor', `WARN this storebound runs from ${tcc}: macOS guards that folder, so a launchd job, CI agent or anything started outside a terminal with access blocks forever when it opens the file (no output, no error). Install it from npm instead: bun add -g storebound@latest (or npm i -g storebound)`); if (r.foreignRsync) log('doctor', `rsync: ${r.foreignRsync} is first on PATH and breaks Xcode exportArchive ("Copy failed") outside storebound; storebound apple build puts /usr/bin first. Fix for everything else: brew unlink rsync, or put /usr/bin before it`); const missing = r.tools.filter(t => !t.found || t.status === 'outdated'); if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; } @@ -106,3 +128,17 @@ export async function run(args: Args): Promise { } log('doctor', `${missing.length} missing. Store work needs none of them; screenshots, previews, icons and captures do.`); } + +/** The real path of the running storebound (a bun link / npm link resolves into the checkout). */ +function realScript(): string { try { return realpathSync(process.argv[1] ?? ''); } catch { return process.argv[1] ?? ''; } } + +/** macOS only: the guarded home folder (Desktop, Documents, Downloads, iCloud Drive) a path lies in, or null. A process + * without access to it (a launchd job, a CI agent) hangs in open() while macOS waits for a consent nobody can give. */ +export function protectedInstall(path: string, home: string, platform: string = process.platform): string | null { + if (platform !== 'darwin' || !path || !home) return null; + for (const d of ['Desktop', 'Documents', 'Downloads', 'Library/Mobile Documents']) { + const dir = `${home.endsWith('/') ? home.slice(0, -1) : home}/${d}`; // posix paths: the check is macOS-only + if (path === dir || path.startsWith(dir + '/')) return `~/${d}`; + } + return null; +} diff --git a/cli/flow.ts b/cli/flow.ts index 63387e1..11151b3 100644 --- a/cli/flow.ts +++ b/cli/flow.ts @@ -20,7 +20,7 @@ import { dirname, join, relative } from 'node:path'; import type { Args } from './lib/args'; import { CONFIG_NAMES, loadConfig, type ShipConfig } from './lib/config'; import { - type CondCtx, expand, type Flow, flowsOfFile, kindOf, type Mode, parseParams, planStep, type PlannedStep, resumePoint, type RunState, startIndex, + type CondCtx, type DryPending, expand, type Flow, flowsOfFile, kindOf, missingParams, type Mode, noteDry, parseParams, planStep, plannedForDryInput, type PlannedStep, resumePoint, type RunState, startIndex, type StepResult, validateFlows, } from './lib/flow'; import { DECLINED_MARK } from './lib/ctx'; @@ -170,6 +170,12 @@ async function runFlow(args: Args, root: string): Promise { const subParams = expand(flow, all, 'steps').params; const vars: Record = { ...subParams, ...flow.params, ...(from ? last?.vars ?? {} : {}), ...params }; const c = await condCtx(root); + vars['config.testflight.internal'] = (c.config?.apple?.testflight?.groups ?? []).filter(g => g.internal).map(g => g.name).join(','); + if (!args.has('resume')) { + const missing = missingParams(vars); + if (missing.length) throw new Error(`${name} needs ${missing.map(k => `${k}=`).join(' ')} (storebound flow run ${name} ${missing.map(k => `${k}=...`).join(' ')})`); + } + const pending: DryPending = { built: new Set(), uploaded: new Set() }; const prebuilt = prebuildsDone(); const state: RunState = { flow: name, mode, startedAt: new Date().toISOString(), vars, results: [], ok: true }; const env = process.env as Record; @@ -196,12 +202,20 @@ async function runFlow(args: Args, root: string): Promise { state.results.push(r); save(); continue; } + const planned = plannedForDryInput(p, mode, pending); + if (planned) { + r.status = 'planned'; r.detail = planned; + if (!json) log(TAG, `${label}: ${describe(p)} (${planned})`); + state.results.push(r); save(); + continue; + } if (!json) log(TAG, `${label}: ${describe(p)}${p.dryAdded ? ' (dry run)' : ''}`); const t = Date.now(); const x = await exec(p, root, json, prebuilt.size ? { [PREBUILD_DONE_ENV]: [...prebuilt].join('\n') } : {}); r.exitCode = x.code; r.ms = Date.now() - t; if (x.code === 0) { r.status = 'ok'; + noteDry(p, pending); const pre = p.argv ? prebuildRan(p.argv, c.config) : null; if (pre) prebuilt.add(pre); if (p.capture) { diff --git a/cli/games-status.ts b/cli/games-status.ts index 5a06489..814732c 100644 --- a/cli/games-status.ts +++ b/cli/games-status.ts @@ -7,7 +7,8 @@ import { ascClient } from './apple/asc'; import { readProjectEntitlements } from './apple/signing'; import type { Loaded } from './lib/config'; import { consoleUrl } from './lib/console-links'; -import { type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, planGameCenter, planPlayGames, pgsFindings, readPgsProject, usesPlayGames } from './lib/game-services'; +import { type Action, type Finding, gamesPrivacyFindings, gcFindings, hasAndroidProject, type ItemPlan, liveGcLocales, onStore, planGameCenter, planPlayGames, pgsFindings, readPgsProject, suggestLocaleMap, usesPlayGames } from './lib/game-services'; +import { parseGoogleError } from './lib/google-errors'; import type { Finding as VFinding } from './lib/validate-rules'; import { gamesConfigClient, playDefaultLocale, readPlayGames } from './play/games'; @@ -18,6 +19,33 @@ export interface GameServicesStatus { config: { leaderboards: number; achievements: number }; changes: string[]; error?: string }; findings: (Finding & { store: 'apple' | 'play' })[]; links: { label: string; url: string }[]; + /** One row per configured leaderboard / achievement: its texts and where each store stands against the config + * (create / update / keep; null = not on that store or that store was not read). */ + items?: GameItemRow[]; + /** Game Center locale map: the configured entries and the suggestions from the live locales. */ + localeMap?: { configured: Record; suggested: { id: string; from: string; to: string }[] }; + /** Play: the Games Configuration API is off in the service account's Cloud project. */ + playApiDisabled?: { service: string; title: string; url: string } | null; +} + +export interface GameItemRow { + kind: 'leaderboard' | 'achievement'; id: string; name: string; locales: string[]; points?: number; hidden?: boolean; + ios: boolean; android: boolean; + apple: { action: Action; changed: string[]; locales: { locale: string; action: Action }[] } | null; + play: { action: Action; changed: string[] } | null; +} + +/** The per-item rows of the status (pure; plans are null when that store was not read). */ +export function gameItemRows(g: NonNullable, applePlan: ItemPlan[] | null, playPlan: ItemPlan[] | null): GameItemRow[] { + const find = (plan: ItemPlan[] | null, kind: ItemPlan['kind'], id: string) => plan?.find(p => p.kind === kind && p.id === id) ?? null; + const row = (kind: ItemPlan['kind'], x: { id: string; locales: Record; platforms?: ('ios' | 'android')[] }, extra: Partial): GameItemRow => { + const a = find(applePlan, kind, x.id), p = find(playPlan, kind, x.id); + return { kind, id: x.id, name: Object.values(x.locales)[0]?.name ?? x.id, locales: Object.keys(x.locales), ...extra, + ios: onStore(x, 'apple'), android: onStore(x, 'play'), + apple: a ? { action: a.action, changed: a.changed, locales: a.locales.map(l => ({ locale: l.locale, action: l.action })) } : null, + play: p ? { action: p.action, changed: p.changed } : null }; + }; + return [...(g.leaderboards ?? []).map(l => row('leaderboard', l, {})), ...(g.achievements ?? []).map(a => row('achievement', a, { points: a.points, hidden: !!a.hidden }))]; } /** Offline findings (check, scan-like): entitlement, APP_ID resource, declarations reminders. */ @@ -37,7 +65,8 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p const { config, root } = loaded; const g = config.games ?? {}; const findings = localGameFindings(loaded); - const out: GameServicesStatus = { apple: null, play: null, findings, links: [] }; + const out: GameServicesStatus = { apple: null, play: null, findings, links: [], localeMap: { configured: { ...(g.localeMap ?? {}) }, suggested: [] }, playApiDisabled: null }; + let applePlan: ItemPlan[] | null = null, playPlan: ItemPlan[] | null = null; const count = (store: 'apple' | 'play') => ({ leaderboards: (g.leaderboards ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length, achievements: (g.achievements ?? []).filter(x => !x.platforms || x.platforms.includes(store === 'apple' ? 'ios' : 'android')).length }); const gcUse = config.apple ? gameCenterInUse(config, root) : { inUse: false, why: null }; @@ -46,8 +75,10 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p try { const c = await ascClient(root, config.apple.bundleId, config.apple.appId); const s = await readGameCenter(c, config.apple.bundleId); + applePlan = planGameCenter(g, config.languages, s, g.localeMap); + out.localeMap!.suggested = suggestLocaleMap(config.languages, liveGcLocales(s), g.localeMap ?? {}); out.apple = { capability: s.capability, enabled: !!s.detail, version: s.version?.versionString ?? null, versionEnabled: s.version ? s.version.enabled : null, - leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(planGameCenter(g, config.languages, s, g.localeMap)), why: gcUse.why ?? undefined }; + leaderboards: s.leaderboards.length, achievements: s.achievements.length, config: count('apple'), changes: changesOf(applePlan), why: gcUse.why ?? undefined }; } catch (e) { out.apple = { capability: null, enabled: false, version: null, versionEnabled: null, leaderboards: 0, achievements: 0, config: count('apple'), changes: [], error: (e as Error).message.slice(0, 300) }; } } if (config.play && usesPlayGames(config) && o.play !== false) { @@ -58,9 +89,14 @@ export async function gameServicesStatus(loaded: Loaded, o: { apple?: boolean; p if (!appId) out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: 'no games project id yet (storebound console plan play-games-services)' }; else try { const have = await readPlayGames(gamesConfigClient(root), appId); - out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(planPlayGames(g, config.languages, playDefaultLocale(config), have)) }; - } catch (e) { out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; } + playPlan = planPlayGames(g, config.languages, playDefaultLocale(config), have); + out.play = { ...base, leaderboards: have.leaderboards.length, achievements: have.achievements.length, changes: changesOf(playPlan) }; + } catch (e) { + const f = parseGoogleError((e as Error).message); + if (f?.kind === 'api-disabled') out.playApiDisabled = { service: f.service, title: f.serviceTitle, url: f.activationUrl }; + out.play = { ...base, leaderboards: 0, achievements: 0, changes: [], error: (e as Error).message.slice(0, 300) }; } } + out.items = gameItemRows(g, applePlan, playPlan); return out; } diff --git a/cli/index.ts b/cli/index.ts index b1e1386..fe3c5a7 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -105,6 +105,7 @@ async function main(): Promise { const loaded = await loadConfig(args.get('config')); const { acquireLock, lockKeys } = await import('./lib/lock'); for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') }); + await playPreflight(key, loaded, args); await (await COMMANDS[key]![0]()).run({ loaded, args }); }; await (args.has('json') ? withJsonOutput(go) : go()); @@ -119,8 +120,20 @@ async function main(): Promise { // one writer per app and store (cli/lib/lock.ts): dry runs and reads take no lock const { acquireLock, lockKeys } = await import('./lib/lock'); for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') }); + await playPreflight(key, loaded, args); await (await COMMANDS[key]![0]()).run({ loaded, args }); }); } +/** Play commands with a credential broker: the package's grants and APIs are read first, and a missing permission or a + * disabled API stops the command before any Play call (a dry run or a read only warns). cli/lib/broker.ts. */ +async function playPreflight(key: string, loaded: { root: string; config: { play?: { packageName: string } } }, args: ReturnType): Promise { + if (!key.startsWith('play ')) return; + const b = await import('./lib/broker'); + if (!b.COMMAND_NEEDS[key]) return; + const { warn } = await import('./lib/log'); + const read = args.has('dry') || ['status', 'pull'].includes(String(args._[0] ?? '')); + await b.playPreflight(key, loaded.root, loaded.config.play?.packageName, { dry: read, warn: m => warn('preflight', m) }); +} + main().catch(e => { console.error(`\nerror: ${(e as Error).message}`); process.exit(1); }); diff --git a/cli/inspect.ts b/cli/inspect.ts index 4c2e7d5..12b65fc 100644 --- a/cli/inspect.ts +++ b/cli/inspect.ts @@ -10,7 +10,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { platformsOf, playCategoryOf, profileOf, sellsOn } from './lib/config'; import type { Ctx } from './lib/ctx'; -import { maskUser, reviewContact, reviewDemoAccount } from './lib/credentials'; +import { credentialValue, maskUser, reviewContact, reviewDemoAccount } from './lib/credentials'; import { DATA_SAFETY_UPLOADED, declarations } from './lib/declarations'; import type { ProjectSummary } from './lib/desktop-protocol'; import { log, printJson } from './lib/log'; @@ -42,8 +42,9 @@ export function summarize(ctx: Ctx): ProjectSummary { profile: profileOf(config), icon: findIcon(config, root), variants: Object.keys(config.shots?.variants ?? {}), - playReports: !!config.play?.reportsBucket, + playReports: !!config.play?.reportsBucket || !!credentialValue(root, 'PLAY_REPORTS_BUCKET'), playRelease: config.play ? { managedPublishing: config.play.managedPublishing ?? null, countries: config.play.countries?.length ?? null, category: playCategoryOf(config)?.category ?? null } : null, + games: config.games ? { gameCenter: config.games.gameCenter === true, playGames: !!config.games.playGames, leaderboards: config.games.leaderboards?.length ?? 0, achievements: config.games.achievements?.length ?? 0 } : null, }; } diff --git a/cli/lib/accounts.ts b/cli/lib/accounts.ts index 940e340..2c57242 100644 --- a/cli/lib/accounts.ts +++ b/cli/lib/accounts.ts @@ -9,6 +9,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSyn import { homedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; import { maskUser, PROFILE_KEYS, PROFILE_RE, profileDir, profileEnv, profileNames } from './credentials'; +import { brokerConfig, profileBrokerOff } from './broker'; export type Kind = 'apple' | 'play'; /** The store keys that move to a profile (one developer account's), never the app's own ones. */ @@ -36,12 +37,19 @@ export function profileIdentity(name: string): Identity { } /** One line per profile for `storebound accounts`: stores, masked Play e-mail. No key ids, no values. */ -export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; playDeveloperId?: string | null; error?: string }[] { +export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; playDeveloperId?: string | null; + /** Where Play tokens come from: the credential broker (~/.storebound/broker.json), the profile's key file, or none. */ + playSource: 'broker' | 'file' | null; error?: string }[] { + let broker = false; + try { broker = !!brokerConfig(); } catch { /* a broken broker.json: doctor reports it */ } return profileNames().map(profile => { try { const id = profileIdentity(profile); - return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor, playDeveloperId: id.playDeveloperId }; - } catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, error: (e as Error).message }; } + const email = id.playEmail ?? profileEnv(profile).PLAY_SERVICE_ACCOUNT_EMAIL ?? null; + const viaBroker = broker && !profileBrokerOff(profile) && !!email; + const playSource = viaBroker ? 'broker' as const : id.playEmail ? 'file' as const : null; + return { profile, apple: !!id.appleKeyId, play: !!playSource, playAccount: email && playSource ? maskUser(email) : null, vendor: id.vendor, playDeveloperId: id.playDeveloperId, playSource }; + } catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, playSource: null, error: (e as Error).message }; } }); } diff --git a/cli/lib/analytics.ts b/cli/lib/analytics.ts index d51f895..90d99fb 100644 --- a/cli/lib/analytics.ts +++ b/cli/lib/analytics.ts @@ -23,6 +23,10 @@ import { mdTable, pct, textTable } from './table'; export const APPLE_REPORTS = { engagement: { category: 'APP_STORE_ENGAGEMENT', match: /discovery and engagement/i }, downloads: { category: 'COMMERCE', match: /download/i }, + // App Usage (Analytics Reports API category APP_USAGE): App Sessions (Sessions, Total Session Duration, Unique Devices) and + // App Crashes (Crashes, Unique Devices), per day, version, device, platform version and territory + sessions: { category: 'APP_USAGE', match: /app sessions/i }, + crashes: { category: 'APP_USAGE', match: /app crashes/i }, } as const; /** Standard over Detailed (Detailed adds noise for privacy and splits by campaign). */ @@ -54,8 +58,16 @@ export function latestPerDate(instances: { processingDate: string; rows: Record< return instances.flatMap(i => i.rows.filter(r => best.get(r.date ?? '') === i.processingDate)); } -export interface AppleDay { date: string; impressions: number; uniqueImpressions: number; pageViews: number; uniquePageViews: number; downloads: number; firstTimeDownloads: number } -export interface PlayDay { date: string; visitors: number; acquisitions: number; installs: number } +export interface AppleDay { + date: string; impressions: number; uniqueImpressions: number; pageViews: number; uniquePageViews: number; downloads: number; firstTimeDownloads: number; + /** App Usage (only when the App Sessions / App Crashes reports have the day): app sessions, active devices (sum of the + * rows' Unique Devices: a device seen in two rows, e.g. two app versions, counts twice), total session time in + * seconds, crashes. */ + sessions?: number; activeDevices?: number; sessionSeconds?: number; crashes?: number; +} +/** installs = Daily User Installs. The two stock numbers are Play's own columns: devices with the app installed that + * were active in the last 30 days (Active Device Installs) and all users who ever installed it (Total User Installs). */ +export interface PlayDay { date: string; visitors: number; acquisitions: number; installs: number; activeDeviceInstalls?: number; totalUserInstalls?: number } const num = (v: string | undefined) => { const n = Number((v ?? '').replace(/,/g, '')); return Number.isFinite(n) ? n : 0; }; @@ -80,12 +92,50 @@ export function appleDaily(engagement: Record[], downloads: Reco return [...days.values()].sort((a, b) => a.date.localeCompare(b.date)); } -/** Play store performance + installs CSV rows (country files) -> one line per day. Dates are YYYY-MM-DD. */ -export function playDaily(storePerformance: Record[], installs: Record[]): PlayDay[] { +/** App Sessions + App Crashes rows -> App Usage fields per day, merged into the days (a day only in the usage reports + * becomes a day with zero impressions and downloads). Sessions' Unique Devices are the active devices of the day. */ +export function mergeAppleUsage(daily: AppleDay[], sessions: Record[], crashes: Record[]): AppleDay[] { + const days = new Map(daily.map(d => [d.date, { ...d }])); + const day = (d: string) => days.get(d) ?? days.set(d, { date: d, impressions: 0, uniqueImpressions: 0, pageViews: 0, uniquePageViews: 0, downloads: 0, firstTimeDownloads: 0 }).get(d)!; + for (const r of sessions) { + if (!r.date) continue; + const x = day(r.date); + x.sessions = (x.sessions ?? 0) + num(r.sessions); + x.activeDevices = (x.activeDevices ?? 0) + num(r.unique_devices); + x.sessionSeconds = (x.sessionSeconds ?? 0) + num(r.total_session_duration); + } + for (const r of crashes) { + if (!r.date) continue; + const x = day(r.date); + x.crashes = (x.crashes ?? 0) + num(r.crashes ?? r.counts); + } + return [...days.values()].sort((a, b) => a.date.localeCompare(b.date)); +} + +/** Play store performance + installs CSV rows (country files) -> one line per day. Dates are YYYY-MM-DD. The stock + * columns (Active Device Installs, Total User Installs) come from the overview rows when given (all countries in one + * row per day), else from the country rows summed. */ +export function playDaily(storePerformance: Record[], installs: Record[], overview: Record[] = []): PlayDay[] { const days = new Map(); const day = (d: string) => days.get(d) ?? days.set(d, { date: d, visitors: 0, acquisitions: 0, installs: 0 }).get(d)!; for (const r of storePerformance) if (r.date) { const x = day(r.date); x.visitors += num(r.store_listing_visitors); x.acquisitions += num(r.store_listing_acquisitions); } for (const r of installs) if (r.date) day(r.date).installs += num(r.daily_user_installs); + const stock = (rows: Record[]) => { + const m = new Map(); + for (const r of rows) { + if (!r.date) continue; + const x = m.get(r.date) ?? m.set(r.date, {}).get(r.date)!; + if (r.active_device_installs !== undefined && r.active_device_installs !== '') x.active = (x.active ?? 0) + num(r.active_device_installs); + if (r.total_user_installs !== undefined && r.total_user_installs !== '') x.total = (x.total ?? 0) + num(r.total_user_installs); + } + return m; + }; + const fromCountries = stock(installs), fromOverview = stock(overview); + for (const [date, x] of (fromOverview.size ? fromOverview : fromCountries)) { + const d = day(date); + if (x.active !== undefined) d.activeDeviceInstalls = x.active; + if (x.total !== undefined) d.totalUserInstalls = x.total; + } return [...days.values()].sort((a, b) => a.date.localeCompare(b.date)); } @@ -101,29 +151,50 @@ export function monthsBetween(start: string, end: string): string[] { export const playObjects = (pkg: string, month: string) => ({ storePerformance: `stats/store_performance/store_performance_${pkg}_${month}_country.csv`, installs: `stats/installs/installs_${pkg}_${month}_country.csv`, + installsOverview: `stats/installs/installs_${pkg}_${month}_overview.csv`, }); /** "gs://pubsite_prod_rev_123/" or "pubsite_prod_rev_123" -> bucket name. */ export const bucketName = (b: string): string => b.replace(/^gs:\/\//, '').replace(/\/.*$/, ''); -export interface Totals { impressions?: number; pageViews?: number; downloads?: number; conversion?: number | null; visitors?: number; acquisitions?: number; installs?: number } +export interface Totals { + impressions?: number; pageViews?: number; downloads?: number; conversion?: number | null; visitors?: number; acquisitions?: number; installs?: number; + /** App Usage: sessions and crashes summed, active devices as the mean of the days that have usage data. */ + sessions?: number; crashes?: number; activeDevicesAvg?: number; + /** Play stock numbers: the value of the newest day that has one (never summed). */ + activeDeviceInstalls?: number; totalUserInstalls?: number; +} export function appleTotals(days: AppleDay[]): Totals { const s = (k: keyof AppleDay) => days.reduce((n, d) => n + (d[k] as number), 0); const uniq = s('uniqueImpressions'); - return { impressions: s('impressions'), pageViews: s('pageViews'), downloads: s('downloads'), conversion: uniq ? s('downloads') / uniq : null }; + const t: Totals = { impressions: s('impressions'), pageViews: s('pageViews'), downloads: s('downloads'), conversion: uniq ? s('downloads') / uniq : null }; + const used = days.filter(d => d.sessions !== undefined || d.crashes !== undefined); + if (used.length) { + t.sessions = used.reduce((n, d) => n + (d.sessions ?? 0), 0); + t.crashes = used.reduce((n, d) => n + (d.crashes ?? 0), 0); + const act = used.filter(d => d.activeDevices !== undefined); + if (act.length) t.activeDevicesAvg = Math.round(act.reduce((n, d) => n + d.activeDevices!, 0) / act.length); + } + return t; } export function playTotals(days: PlayDay[]): Totals { const s = (k: keyof PlayDay) => days.reduce((n, d) => n + (d[k] as number), 0); const v = s('visitors'); - return { visitors: v, acquisitions: s('acquisitions'), installs: s('installs'), conversion: v ? s('acquisitions') / v : null }; + const t: Totals = { visitors: v, acquisitions: s('acquisitions'), installs: s('installs'), conversion: v ? s('acquisitions') / v : null }; + const last = (k: 'activeDeviceInstalls' | 'totalUserInstalls') => [...days].reverse().find(d => d[k] !== undefined)?.[k]; + const a = last('activeDeviceInstalls'), tot = last('totalUserInstalls'); + if (a !== undefined) t.activeDeviceInstalls = a; + if (tot !== undefined) t.totalUserInstalls = tot; + return t; } export interface Snapshot { date: string; window: { start: string; end: string; days: number }; - apple?: { daily: AppleDay[]; totals: Totals } | { error: string }; - play?: { daily: PlayDay[]; totals: Totals } | { error: string }; + /** notes: what is missing but not fatal (e.g. the App Usage reports are not listed yet). */ + apple?: { daily: AppleDay[]; totals: Totals; notes?: string[] } | { error: string }; + play?: { daily: PlayDay[]; totals: Totals; notes?: string[] } | { error: string }; } /** diff --git a/cli/lib/broker.ts b/cli/lib/broker.ts new file mode 100644 index 0000000..fb8f3b5 --- /dev/null +++ b/cli/lib/broker.ts @@ -0,0 +1,359 @@ +// Credential broker: a server of the owner's that holds the Google service-account keys, the Cloud project facts and +// the Play permission grants, so this machine needs no Google key of its own. storebound asks it for short-lived +// Google access tokens (POST /api/google/token) and for the Play facts of one package (GET +// /api/google/play//storebound: the service account's Play permissions, the Cloud projects' enabled APIs, the +// SDK config files). docs/credential-broker.md. +// +// Config: ~/.storebound/broker.json (file: env STOREBOUND_BROKER_CONFIG) +// { "url": "https://broker.example.com", "apiKeyFile": "~/.storebound/broker.key", "account"?: "" } +// env STOREBOUND_BROKER_URL / STOREBOUND_BROKER_KEY_FILE override the file. Which Google account to mint for: +// PLAY_SERVICE_ACCOUNT_EMAIL (env, the credential profile, .storebound/credentials.json), else "account" in broker.json, +// else the client_email of a local key, else the account the broker names for the package. Off: env +// STOREBOUND_BROKER=off, or "broker": false in a credential profile (that profile keeps its local key). +// The key file holds one scoped API key (google:read google:token); on macOS / Linux it must be mode 600 (a group or +// world readable file is refused). Tokens live in memory only, until 5 minutes before they expire, one request per +// account + scope at a time; they are never written or logged, and every error text has ya29. tokens and the API key +// redacted. +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { activeProfile, credentialValue, PROFILE_RE, profileDir } from './credentials'; +import { enableUrl, PLAY_USERS_URL } from './google-errors'; + +export type BrokerScope = 'androidpublisher' | 'cloud-platform.read-only' | 'firebase.readonly'; +export const BROKER_SCOPES: readonly BrokerScope[] = ['androidpublisher', 'cloud-platform.read-only', 'firebase.readonly']; + +export interface BrokerConfig { + url: string; + keyFile: string; + /** The Google account (service account e-mail) to mint tokens for, when configured. */ + account: string | null; + /** Where the URL came from: "env" or the broker.json path. */ + from: string; +} + +/** The broker did not answer (network error, timeout, 5xx): callers fall back to a local key when there is one. */ +export class BrokerUnavailable extends Error {} +/** The broker refused this machine's API key (401 / 403): no fallback, no retry; the key needs its scopes. */ +export class BrokerRefused extends Error { constructor(msg: string, readonly status: number) { super(msg); } } +/** The broker has no such Google account (400 / 404 on a token request): a local key may still serve it. */ +export class BrokerUnknownAccount extends Error {} + +const OFF = /^(off|0|false|no)$/i; +export const brokerConfigFile = (): string => process.env.STOREBOUND_BROKER_CONFIG || join(homedir(), '.storebound', 'broker.json'); +const expand = (p: string, base: string): string => { const x = p.replace(/^~(?=$|[/\\])/, homedir()); return isAbsolute(x) ? x : resolve(base, x); }; + +/** A profile that says "broker": false keeps its local key (also env STOREBOUND_PROFILE__BROKER=off). */ +export function profileBrokerOff(name: string): boolean { + if (!PROFILE_RE.test(name)) return false; + if (OFF.test(process.env[`STOREBOUND_PROFILE_${name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BROKER`] ?? '')) return true; + try { + const f = join(profileDir(), `${name}.json`); + if (!existsSync(f)) return false; + const v = (JSON.parse(readFileSync(f, 'utf8')) as Record).broker; + return v === false || (typeof v === 'string' && OFF.test(v)); + } catch { return false; } +} +function profileOptsOut(root: string): boolean { + try { const p = activeProfile(root); return !!p && profileBrokerOff(p.name); } catch { return false; } +} + +/** Only https, or http to this machine (a local test broker): the API key never travels in clear text. */ +export function checkBrokerUrl(raw: string): string { + let u: URL; + try { u = new URL(raw); } catch { throw new Error(`credential broker url "${raw}" is not a URL (https://broker.example.com)`); } + const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname); + if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error(`credential broker url ${u.origin}: https only (http only for localhost)`); + return u.origin + u.pathname.replace(/\/+$/, ''); +} + +/** + * The broker config for a project (null: not configured, or turned off for it). Throws on an unreadable broker.json or + * a bad URL: a broken config is reported, never silently skipped. + */ +export function brokerConfig(root?: string): BrokerConfig | null { + if (OFF.test(process.env.STOREBOUND_BROKER ?? '')) return null; + if (root && profileOptsOut(root)) return null; + const f = brokerConfigFile(); + let file: { url?: unknown; apiKeyFile?: unknown; account?: unknown } = {}; + if (existsSync(f)) { + try { file = JSON.parse(readFileSync(f, 'utf8')) as typeof file; } catch (e) { throw new Error(`${f}: ${(e as Error).message}`); } + if (!file || typeof file !== 'object' || Array.isArray(file)) throw new Error(`${f}: an object { "url", "apiKeyFile" }`); + } + const envUrl = process.env.STOREBOUND_BROKER_URL; + const raw = envUrl || (typeof file.url === 'string' ? file.url : ''); + if (!raw) return null; + const keyRaw = process.env.STOREBOUND_BROKER_KEY_FILE || (typeof file.apiKeyFile === 'string' && file.apiKeyFile ? file.apiKeyFile : join(dirname(f), 'broker.key')); + let account: string | null = null; + try { account = (root ? credentialValue(root, 'PLAY_SERVICE_ACCOUNT_EMAIL') : process.env.PLAY_SERVICE_ACCOUNT_EMAIL) || null; } catch { /* unreadable project file */ } + if (!account && typeof file.account === 'string' && file.account) account = file.account; + return { url: checkBrokerUrl(raw), keyFile: expand(keyRaw, dirname(f)), account, from: envUrl ? 'env' : f }; +} + +/** The API key from the key file. Refuses a group / world readable file on POSIX. Never printed. */ +export function brokerKey(cfg: BrokerConfig, platform: NodeJS.Platform = process.platform): string { + let mode: number; + try { mode = statSync(cfg.keyFile).mode; } catch { throw new Error(`credential broker key file ${cfg.keyFile} not found: put the API key there (one line, chmod 600)`); } + if (platform !== 'win32' && (mode & 0o077)) throw new Error(`refusing the credential broker key file ${cfg.keyFile}: it is readable by group or others (mode ${(mode & 0o777).toString(8)}); run chmod 600 ${cfg.keyFile}`); + const k = readFileSync(cfg.keyFile, 'utf8').trim(); + if (!k || /\s/.test(k)) throw new Error(`credential broker key file ${cfg.keyFile}: one API key on one line`); + return k; +} + +/** An error text without Google access tokens, bearer values or the given secrets. */ +export function redact(text: string, ...secrets: (string | null | undefined)[]): string { + let s = String(text ?? '').replace(/ya29\.[A-Za-z0-9._~+/=-]+/g, 'ya29.***').replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1***'); + for (const x of secrets) if (x && x.length >= 6) s = s.split(x).join('***'); + return s; +} + +const host = (cfg: BrokerConfig): string => { try { return new URL(cfg.url).host; } catch { return cfg.url; } }; +export const brokerTimeoutMs = (): number => Number(process.env.STOREBOUND_BROKER_TIMEOUT_MS) || 15_000; + +interface Answer { status: number; json: unknown; text: string } + +async function request(cfg: BrokerConfig, method: 'GET' | 'POST', path: string, body?: unknown): Promise { + const key = brokerKey(cfg); + let r: Response; + try { + r = await fetch(cfg.url + path, { + method, signal: AbortSignal.timeout(brokerTimeoutMs()), + headers: { Authorization: `Bearer ${key}`, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (e) { throw new BrokerUnavailable(`credential broker ${host(cfg)} did not answer: ${redact((e as Error).message, key)}`); } + const raw = await r.text().catch(() => ''); + let json: unknown = null; + try { json = raw ? JSON.parse(raw) : null; } catch { /* not JSON */ } + const text = redact(raw, key).slice(0, 300); + if (r.status >= 500) throw new BrokerUnavailable(`credential broker ${host(cfg)} -> ${r.status} ${text}`.trim()); + return { status: r.status, json, text }; +} + +const errOf = (a: Answer): string => { + const j = a.json as { error?: unknown; message?: unknown } | null; + const m = j && typeof j === 'object' ? [j.error, j.message].filter(x => typeof x === 'string').join(': ') : ''; + return m || a.text; +}; + +const tokens = new Map(); +const inflight = new Map>(); +const infos = new Map>(); + +/** Forgets every cached token and package answer (tests; a long-running process after a key change). */ +export function clearBrokerCache(): void { tokens.clear(); inflight.clear(); infos.clear(); } + +/** + * A Google access token for `account` with one broker scope. Cached in memory until 5 minutes before `expiresAt`; + * parallel callers share one request. 401 / 403 throw BrokerRefused (no retry), 400 / 404 BrokerUnknownAccount, no + * answer BrokerUnavailable. + */ +export async function brokerToken(cfg: BrokerConfig, account: string, scope: BrokerScope, now: () => number = Date.now): Promise { + const k = `${cfg.url}\n${account}\n${scope}`; + const hit = tokens.get(k); + if (hit && now() < hit.until) return hit.token; + const running = inflight.get(k); + if (running) return running; + const p = (async () => { + const a = await request(cfg, 'POST', '/api/google/token', { account, scopes: [scope] }); + if (a.status === 401 || a.status === 403) + throw new BrokerRefused(`the credential broker ${host(cfg)} refused the token request (${a.status}${errOf(a) ? `: ${errOf(a)}` : ''}): its API key lacks the google:token scope or was revoked. Give the key google:read and google:token on the broker; storebound does not retry.`, a.status); + if (a.status === 400 || a.status === 404) + throw new BrokerUnknownAccount(`the credential broker ${host(cfg)} has no Google account ${account} for scope ${scope} (${a.status}${errOf(a) ? `: ${errOf(a)}` : ''}): import its key on the broker, or set PLAY_SERVICE_ACCOUNT_EMAIL to an account it holds`); + const j = a.json as { accessToken?: unknown; expiresAt?: unknown } | null; + if (a.status !== 200 || typeof j?.accessToken !== 'string' || !j.accessToken || typeof j.expiresAt !== 'number') + throw new Error(`credential broker ${host(cfg)} token -> ${a.status}: no accessToken / expiresAt in the answer`); + const exp = j.expiresAt > 1e12 ? j.expiresAt : j.expiresAt * 1000; + tokens.set(k, { token: j.accessToken, until: exp - 300_000 }); + return j.accessToken; + })().finally(() => inflight.delete(k)); + inflight.set(k, p); + return p; +} + +export interface BrokerPlayInfo { + packageName: string; + developerId?: string | null; + account?: string | null; + accountPermissions?: { developer?: string[]; app?: string[] } | null; + grantsCheckedAt?: number | null; + projects?: { projectId: string; projectNumber?: string; enabledApis?: string[]; billing?: string }[]; + sdkConfigs?: { platform: string; sha256?: string; projectId?: string; firebaseAppId?: string; bundle?: string; drift?: boolean }[]; + /** Optional fix links the broker may hand out. */ + grantUrl?: string; +} + +export type PlayInfoResult = + | { kind: 'ok'; info: BrokerPlayInfo } + /** 404: the package is not registered on the broker (not "no access"). */ + | { kind: 'not-registered'; message: string } + | { kind: 'refused'; message: string } + | { kind: 'unavailable'; message: string }; + +/** What the broker knows about one Play package (once per run and package). Never throws. */ +export function brokerPlayInfo(cfg: BrokerConfig, pkg: string): Promise { + const k = `${cfg.url}\n${pkg}`; + const cached = infos.get(k); + if (cached) return cached; + const p = (async (): Promise => { + try { + const a = await request(cfg, 'GET', `/api/google/play/${encodeURIComponent(pkg)}/storebound`); + if (a.status === 404) return { kind: 'not-registered', message: `${pkg} is not registered on the credential broker ${host(cfg)}: register the package there (google_play_set) to get its permission checks; this is not a missing Play access` }; + if (a.status === 401 || a.status === 403) return { kind: 'refused', message: `the credential broker ${host(cfg)} refused the package read (${a.status}): its API key lacks the google:read scope` }; + const j = a.json as BrokerPlayInfo | null; + if (a.status !== 200 || !j || typeof j !== 'object') return { kind: 'unavailable', message: `credential broker ${host(cfg)} -> ${a.status} ${a.text}`.trim() }; + return { kind: 'ok', info: { ...j, packageName: typeof j.packageName === 'string' ? j.packageName : pkg } }; + } catch (e) { + if (!(e instanceof BrokerUnavailable)) infos.delete(k); + return { kind: 'unavailable', message: (e as Error).message }; + } + })(); + infos.set(k, p); + return p; +} + +/** The Play permission each kind of work needs (Play Console's app permission names, as the broker reports them). */ +export const PLAY_GRANTS = { + upload: 'CAN_MANAGE_TRACK_APKS', + listing: 'CAN_MANAGE_PUBLIC_LISTING', + iap: 'CAN_MANAGE_ORDERS', + declarations: 'CAN_MANAGE_APP_CONTENT', + reviews: 'CAN_REPLY_TO_REVIEWS', +} as const; +export type PlayNeed = keyof typeof PLAY_GRANTS | 'games'; +const NEED_WORDS: Record = { upload: 'uploads and releases', listing: 'the store listing and images', iap: 'in-app products and subscriptions', declarations: 'Data safety and app content', reviews: 'review replies', games: 'Play Games Services configuration' }; +export const ANDROIDPUBLISHER_API = 'androidpublisher.googleapis.com'; +export const GAMES_CONFIG_SERVICE = 'gamesconfiguration.googleapis.com'; + +/** What each storebound command needs from Play (the preflight before it runs). */ +export const COMMAND_NEEDS: Record = { + 'play upload': ['upload'], 'play release': ['upload'], 'play rollout': ['upload'], 'play share': ['upload'], + 'play listing': ['listing'], 'play images': ['listing'], 'play products': ['iap'], 'play subscriptions': ['iap'], + 'play data-safety': ['declarations'], 'play games': ['games'], +}; + +export interface AccessGap { + kind: 'grant' | 'api'; + need: PlayNeed; + /** The missing permission (CAN_MANAGE_TRACK_APKS) or API (gamesconfiguration.googleapis.com). */ + name: string; + message: string; + fix: string; + url: string; +} + +/** The grants of a package as one list (developer-level permissions count for every app). */ +export const grantsOf = (info: BrokerPlayInfo): string[] | null => { + const p = info.accountPermissions; + if (!p || (!Array.isArray(p.app) && !Array.isArray(p.developer))) return null; + return [...new Set([...(p.developer ?? []), ...(p.app ?? [])])].sort(); +}; +/** Every API the broker saw enabled in the package's Cloud projects; null when it reported none. */ +export const enabledApisOf = (info: BrokerPlayInfo): string[] | null => { + const ps = (info.projects ?? []).filter(p => Array.isArray(p.enabledApis)); + return ps.length ? [...new Set(ps.flatMap(p => p.enabledApis!))].sort() : null; +}; + +/** The missing Play permissions and disabled APIs for `needs`, per the broker's answer. Unknown facts are no gap. */ +export function playGaps(info: BrokerPlayInfo, needs: PlayNeed[]): AccessGap[] { + const pkg = info.packageName, who = info.account ?? 'the service account'; + const gaps: AccessGap[] = []; + const grants = grantsOf(info); + const usersUrl = info.grantUrl ?? (info.developerId && /^\d+$/.test(info.developerId) ? `https://play.google.com/console/u/0/developers/${info.developerId}/users-and-permissions` : PLAY_USERS_URL); + for (const need of new Set(needs)) { + if (need === 'games' || !grants) continue; + const perm = PLAY_GRANTS[need]; + if (grants.includes(perm)) continue; + gaps.push({ kind: 'grant', need, name: perm, url: usersUrl, + message: `${who} lacks the Play permission ${perm} for ${pkg} (needed for ${NEED_WORDS[need]})`, + fix: `grant ${perm} for ${pkg} on the credential broker (google_play_grant), or in Play Console > Users and permissions > ${who} > App permissions: ${usersUrl}` }); + } + const apis = enabledApisOf(info); + if (apis) { + const project = (info.projects ?? []).find(p => Array.isArray(p.enabledApis))?.projectId ?? null; + const want = new Set([ANDROIDPUBLISHER_API, ...(needs.includes('games') ? [GAMES_CONFIG_SERVICE] : [])]); + for (const api of want) { + if (apis.includes(api)) continue; + const url = enableUrl(api, project); + gaps.push({ kind: 'api', need: api === GAMES_CONFIG_SERVICE ? 'games' : needs[0] ?? 'upload', name: api, url, + message: `${api} is not enabled in Google Cloud project ${project ?? '(the service account\'s project)'} (needed for ${api === GAMES_CONFIG_SERVICE ? NEED_WORDS.games : 'every Play API call'})`, + fix: `enable ${api} on the credential broker (google_api_enable), or open ${url} and press Enable` }); + } + } + return gaps; +} + +const SDK_FILES: Record = { + android: ['android/app/google-services.json', 'app/google-services.json', 'google-services.json', 'android/google-services.json'], + ios: ['ios/App/App/GoogleService-Info.plist', 'ios/Runner/GoogleService-Info.plist', 'ios/GoogleService-Info.plist', 'GoogleService-Info.plist'], +}; + +/** SDK config files the broker says drifted, or whose sha256 differs from the repo's copy. */ +export function sdkConfigGaps(info: BrokerPlayInfo, root: string): string[] { + const out: string[] = []; + for (const c of info.sdkConfigs ?? []) { + const label = `${c.platform} SDK config${c.bundle ? ` (${c.bundle})` : ''}`; + if (c.drift) out.push(`${label}: the credential broker reports drift from the Firebase project ${c.projectId ?? ''}; download it again on the broker`.replace(/ ;/, ';')); + const file = (SDK_FILES[c.platform] ?? []).map(f => join(root, f)).find(f => existsSync(f)); + if (file && c.sha256 && /^[0-9a-f]{64}$/i.test(c.sha256)) { + const sha = createHash('sha256').update(readFileSync(file)).digest('hex'); + if (sha.toLowerCase() !== c.sha256.toLowerCase()) out.push(`${label}: ${file} differs from the broker's copy (sha256 ${sha.slice(0, 12)}... vs ${c.sha256.slice(0, 12)}...): take the broker's file`); + } + } + return out; +} + +/** One error text for the gaps that stop a command. */ +export const gapsText = (gaps: AccessGap[]): string => gaps.map(g => `${g.message}. Fix: ${g.fix}`).join('\n'); + +/** + * Before a Play command runs (cli/index.ts): with a broker, read the package's grants and APIs and stop early with the + * exact missing permission / API and its link. A dry run only warns. No broker, an unreachable one, or a package the + * broker does not know: a note, then the command runs (Google answers for itself). + */ +export async function playPreflight(command: string, root: string, pkg: string | undefined, o: { dry: boolean; warn: (msg: string) => void }): Promise { + const needs = COMMAND_NEEDS[command]; + if (!needs || !pkg) return []; + let cfg: BrokerConfig | null; + try { cfg = brokerConfig(root); } catch (e) { o.warn((e as Error).message); return []; } + if (!cfg) return []; + const r = await brokerPlayInfo(cfg, pkg); + if (r.kind !== 'ok') { o.warn(r.message); return []; } + const gaps = playGaps(r.info, needs); + if (!gaps.length) return []; + if (o.dry) { for (const g of gaps) o.warn(`${g.message}. Fix: ${g.fix}`); return gaps; } + throw new Error(`${command} stopped before any call to Google Play:\n${gapsText(gaps)}`); +} + +export type StoreKind = 'asc-api-key' | 'play-service-account'; + +/** + * A store credential of one profile from the broker's vault (GET /api/store-credentials//, a key with + * the store-credentials scope): { secret, metadata }. For a long-running server (`mcp --http --broker-profiles`) that + * keeps no key files; null when the broker has none for that profile. Never logged. + */ +export async function brokerStoreCredential(cfg: BrokerConfig, profile: string, kind: StoreKind): Promise<{ secret: string; metadata: Record } | null> { + const a = await request(cfg, 'GET', `/api/store-credentials/${encodeURIComponent(profile)}/${kind}`); + if (a.status === 404) return null; + if (a.status === 401 || a.status === 403) throw new BrokerRefused(`credential broker ${host(cfg)} refused store credentials for ${profile} (${a.status} ${errOf(a)}): the key needs the store-credentials scope and the profile`, a.status); + const j = a.json as { secret?: unknown; metadata?: unknown } | null; + if (a.status !== 200 || !j || typeof j.secret !== 'string') throw new BrokerUnavailable(`credential broker ${host(cfg)} -> ${a.status} for ${profile}/${kind}`); + return { secret: j.secret, metadata: j.metadata && typeof j.metadata === 'object' ? (j.metadata as Record) : {} }; +} + +/** The env a child process needs to use these profiles from the broker (STOREBOUND_PROFILE__, inline PEM / + * JSON; see credentials.ts). Profiles the broker has nothing for are left out. */ +export async function brokerProfileEnv(cfg: BrokerConfig, profiles: string[]): Promise> { + const env: Record = {}; + const name = (p: string, k: string) => `STOREBOUND_PROFILE_${p.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_${k}`; + await Promise.all(profiles.map(async p => { + const [asc, play] = await Promise.all([brokerStoreCredential(cfg, p, 'asc-api-key'), brokerStoreCredential(cfg, p, 'play-service-account')]); + if (asc) { + const { issuerId, keyId } = asc.metadata; + if (typeof issuerId === 'string' && typeof keyId === 'string') Object.assign(env, { [name(p, 'ASC_P8')]: asc.secret, [name(p, 'ASC_KEY_ID')]: keyId, [name(p, 'ASC_ISSUER_ID')]: issuerId }); + } + if (play) env[name(p, 'PLAY_SERVICE_ACCOUNT')] = play.secret; + })); + return env; +} diff --git a/cli/lib/cli-shim.ts b/cli/lib/cli-shim.ts new file mode 100644 index 0000000..8ece9b1 --- /dev/null +++ b/cli/lib/cli-shim.ts @@ -0,0 +1,81 @@ +// The desktop app's own storebound on the terminal: the app ships the CLI as its sidecar, and on every start the sidecar +// puts a small launcher on the person's PATH, so `storebound` works in a terminal without an npm install. +// Windows %LOCALAPPDATA%\storebound\bin\storebound.cmd, that folder appended to the user PATH (once) +// macOS / Linux ~/.local/bin/storebound (a sh launcher), ~/.local/bin added to ~/.zprofile / ~/.profile when missing +// An npm / bun install stays first: the folder is appended, and a launcher is never written over a file that is not +// ours (the marker line). Rewritten on every start, so a moved or updated app fixes itself. Not from an AppImage (its +// mount path changes per run) or a dev run. STOREBOUND_NO_CLI_SHIM=1 turns it off. +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, appendFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import * as rt from './rt'; + +export const SHIM_MARKER = 'storebound desktop launcher'; + +export interface ShimPlan { + /** Why nothing is written (null: write). */ + skip: string | null; + file: string; + content: string; + /** The folder to add to PATH when it is not on it (Windows: the user PATH; else a shell profile line). */ + pathDir: string; + /** macOS / Linux: the shell profile that gets the PATH line. */ + profile?: string; +} + +/** What to write for the sidecar at `exe`. Pure. */ +export function shimPlan(o: { exe: string; platform: string; home: string; env: Record }): ShimPlan { + const win = o.platform === 'win32'; + const local = o.env.LOCALAPPDATA ?? join(o.home, 'AppData', 'Local'); + const dir = win ? join(local, 'storebound', 'bin') : join(o.home, '.local', 'bin'); + const file = join(dir, win ? 'storebound.cmd' : 'storebound'); + const content = win + ? `@echo off\r\nrem ${SHIM_MARKER}\r\n"${o.exe}" %*\r\n` + : `#!/bin/sh\n# ${SHIM_MARKER}\nexec '${o.exe.replace(/'/g, `'\\''`)}' "$@"\n`; + const skip = o.env.STOREBOUND_NO_CLI_SHIM ? 'STOREBOUND_NO_CLI_SHIM is set' + : o.env.APPIMAGE || /^\/tmp\/\.mount_/.test(o.exe) ? 'an AppImage (its path changes per run)' + : null; + return { skip, file, content, pathDir: dir, ...(win ? {} : { profile: join(o.home, o.platform === 'darwin' ? '.zprofile' : '.profile') }) }; +} + +/** True when `dir` is one of the PATH entries (case-insensitive on Windows). Pure. */ +export function onPath(dir: string, path: string | undefined, platform: string): boolean { + const norm = (s: string) => { const t = s.replace(/[\\/]+$/, ''); return platform === 'win32' ? t.toLowerCase() : t; }; + return (path ?? '').split(platform === 'win32' ? ';' : ':').some(p => p && norm(p) === norm(dir)); +} + +/** Writes the launcher and the PATH entry; returns what it did, one line each. Never throws. */ +export function ensureCliShim(exe: string = process.execPath, platform: string = process.platform, env: Record = process.env, home: string = homedir()): string[] { + const done: string[] = []; + try { + const p = shimPlan({ exe, platform, home, env }); + if (p.skip) return [`terminal launcher skipped: ${p.skip}`]; + if (existsSync(p.file)) { + const cur = readFileSync(p.file, 'utf8'); + if (!cur.includes(SHIM_MARKER)) return [`terminal launcher not written: ${p.file} exists and is not ours`]; + if (cur === p.content) done.push(`terminal launcher up to date: ${p.file}`); + } + if (!done.length) { + mkdirSync(p.pathDir, { recursive: true }); + writeFileSync(p.file, p.content); + if (platform !== 'win32') chmodSync(p.file, 0o755); + done.push(`terminal launcher written: ${p.file}`); + } + if (platform === 'win32') { + // the user PATH from the registry (not this process's, which a running app inherited before the change) + const q = rt.spawnSync(['reg', 'query', 'HKCU\\Environment', '/v', 'Path'], { stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); + const userPath = /Path\s+REG_(?:EXPAND_)?SZ\s+(.*)/i.exec(new TextDecoder().decode(q.stdout ?? new Uint8Array()))?.[1]?.trim() ?? ''; + if (!onPath(p.pathDir, userPath, platform)) { + // SetEnvironmentVariable keeps REG_EXPAND_SZ entries as typed and tells open Explorer windows (new terminals see it) + const ps = `$p=[Environment]::GetEnvironmentVariable('Path','User'); if(-not $p){$p=''}; if(($p -split ';') -notcontains '${p.pathDir}'){ [Environment]::SetEnvironmentVariable('Path', ($p.TrimEnd(';') + ';${p.pathDir}').TrimStart(';'), 'User') }`; + const r = rt.spawnSync(['powershell', '-NoProfile', '-NonInteractive', '-Command', ps], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); + done.push(r.success ? `added to the user PATH: ${p.pathDir} (new terminals)` : `user PATH not changed (exit ${r.exitCode})`); + } + } else if (!onPath(p.pathDir, env.PATH, platform) && p.profile) { + const line = `export PATH="$PATH:$HOME/.local/bin" # ${SHIM_MARKER}`; + const cur = existsSync(p.profile) ? readFileSync(p.profile, 'utf8') : ''; + if (!cur.includes(SHIM_MARKER)) { appendFileSync(p.profile, `${cur && !cur.endsWith('\n') ? '\n' : ''}${line}\n`); done.push(`added ~/.local/bin to PATH in ${p.profile} (new terminals)`); } + } + } catch (e) { done.push(`terminal launcher failed: ${(e as Error).message.split('\n')[0]}`); } + return done; +} diff --git a/cli/lib/config.ts b/cli/lib/config.ts index e0a52fb..74313a6 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -18,6 +18,7 @@ import { type AppleSigning, validateSigning } from './signing'; import { type AppleBuildConfig, validateAppleBuild } from './xcode'; import { type PlayBuildConfig, validatePlayBuild } from './gradle'; import { type OriginalityConfig, validateOriginality } from './originality'; +import { validateReleaseType } from './release-type'; /** consumable / non_consumable: both stores. non_renewing: Apple NON_RENEWING_SUBSCRIPTION (access for a fixed time, * you track the expiry); Google Play has no such type, so it becomes a one-time product your app consumes when the @@ -201,7 +202,9 @@ export interface ShotsConfig { /** Tablet frames: default 1032 x 1352 @2x (13" iPad minus its 24 px bar) with apple.ipad, else 800 x 1240 @2x. */ tablet?: { w: number; h: number; dpr?: number; bar?: number }; /** Landscape layout (Mac, Chromebook, and the Play tablet slots with playTablets): copy on one side, the frame on the - * other. Frames: //landscape/.png at `size` CSS px (default 1440 x 900 @2x). frame: 'window' (a + * other. Frames: //landscape/.png at `size` CSS px (default 1440 x 900 @2x = 2880 x 1800), made by + * `shots capture --device landscape` (web source: a desktop viewport, no touch, no status bar crop; ios-sim / android-emu + * have no landscape device, use a web build or a folder). frame: 'window' (a * Mac window around the game, the default for mac) or 'none' (the bare frame, the default for Play sizes). side: * where the copy goes (default 'left'). playTablets: render tab7 / tab10 landscape (1920 x 1080 / 2560 x 1440). */ landscape?: { frame?: 'window' | 'none'; side?: 'left' | 'right'; playTablets?: boolean; size?: { w: number; h: number; dpr?: number } }; @@ -216,7 +219,7 @@ export interface ShotsConfig { /** One raw frame. Web: `path` (+ query). Simulator / emulator: `url` deep link and / or `args` / `extras`. */ /** Web capture: a selector, candidates tried in order, or per layout { phone, tablet, any } (own list first). Prefer data * attributes (`[data-shot="shop-row"]`); `text=Shop` and `role=button[name="Shop"]` work as fallbacks. */ -export type ShotSelector = string | string[] | { phone?: string | string[]; tablet?: string | string[]; any?: string | string[] }; +export type ShotSelector = string | string[] | { phone?: string | string[]; tablet?: string | string[]; landscape?: string | string[]; any?: string | string[] }; export interface ShotSpec { /** Web: wait (up to the waitFor timeout) until one of these matches, before waitFor; a miss names the selector and the size. */ @@ -234,7 +237,10 @@ export interface ShotSpec { delayMs?: number; /** Web: JS run in the page before the screenshot. */ eval?: string[]; - device?: 'phone' | 'tablet' | 'both'; + /** Which frames this shot makes: phone (default), tablet, both (phone + tablet), landscape (/landscape/.png at + * shots.landscape.size, web source only). When the project renders a landscape size (apple.mac, play.chromebook, + * shots.landscape.playTablets), shots without a device and 'both' shots also make a landscape frame. */ + device?: 'phone' | 'tablet' | 'both' | 'landscape'; /** Web, capture.session "per-language": how this shot is reached in the already booted page. "spa" (default): * history.pushState to its path + a popstate event, then waitFor / eval (switch scenes from those); "reload": * page.goto in the same page. A failing waitFor there falls back to a fresh page. */ @@ -658,6 +664,7 @@ export interface ShipConfig { billingPrograms?: ('user-choice-billing' | 'alternative-billing' | 'external-offers' | 'external-content-links')[]; }; apple?: { + /** Absent / null only in a store-only config with apple.appId (analytics and reports reads); every write needs it. */ bundleId: string; appId?: string; primaryLocale?: string; listing?: string; info?: AppleAppInfo; reviewShots?: string; /** App Store name in the primary locale (at most 30 characters) when it differs from app.name, e.g. because the plain * name is taken on the App Store ("STAR DASH: Stone Age to 2100"). Default app.name. A `### name` block in the @@ -677,6 +684,14 @@ export interface ShipConfig { screenshotTypes?: string[]; /** The app runs on iPad: renders, checks and uploads 13" iPad screenshots (2064 x 2752) and iPad app previews. */ ipad?: boolean; + /** How an approved version goes live: "afterApproval" (released as soon as App Review approves it), "manual" (waits in + * Pending Developer Release for `apple release --manual-release`) or "scheduled" (not before earliestReleaseDate). + * Applied by `apple listing` when it creates a version and by `apple submit` before submitting (old -> new in the dry + * run); `apple release --after-approval | --manual` changes it on an editable version. Unset: new versions are MANUAL + * and existing ones are left as they are. */ + releaseType?: 'afterApproval' | 'manual' | 'scheduled'; + /** releaseType "scheduled": the earliest release, ISO 8601 date and time (2027-03-01T09:00:00Z). */ + earliestReleaseDate?: string; /** * Also render, check and upload a 6.3" iPhone set (Dynamic Island, medium display, 1206 x 2622 -> APP_IPHONE_61). * Off by default: App Store Connect lists a medium-display screenshot as required for iPhone but fills it by @@ -685,13 +700,26 @@ export interface ShipConfig { */ iphoneMedium?: boolean; /** Also render and check iPhone Duo (foldable) screenshots: outer 1398 x 2034 and inner 2007 x 2853 (landscape too). - * Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. Not uploaded while the API has no - * display type for them (cli/shots/spec.ts IPHONE_DUO_DISPLAY). */ + * Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. `apple screenshots` uploads them through + * the App Asset Library (display class IPHONE_DUO; there is no Duo ScreenshotDisplayType). */ iphoneDuo?: boolean; /** Also render, check and upload Mac App Store screenshots (size key `mac`, 2880 x 1800, 16:10; Apple also takes * 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from //landscape/ (shots.landscape). Uploaded * as APP_DESKTOP to the macOS version: `apple screenshots --platform macos`. */ mac?: boolean; + /** + * macOS App Store overrides (not `mac`, which only switches Mac screenshots on). An app with an iOS and a macOS + * version often needs different version texts and review notes (the Mac app may have no Game Center, in-app + * purchases or ads). `listing`: an App Store listing file used instead of apple.listing for `apple listing + * --platform macos`; `reviewNotes`: used instead of apple.info.reviewNotes by `apple review-details --platform macos`. + * `gameCenter: true`: the Mac app has Game Center (silences the Game Center check of `validate --platform macos`). + * `noIap: true`: the Mac app sells nothing (validate then flags in-app purchase lines in the Mac texts). + * `entitlements`: the Mac App Store entitlements plist of `apple build --platform macos --tauri` (e.g. "src-tauri/Entitlements.appstore.plist"), + * used instead of the Tauri config's bundle.macOS.entitlements (a project with a Developer ID channel keeps that one there); `--entitlements ` wins over it. + * `prebuild`: shell command `apple build --platform macos` runs first in the project root (build.prebuild / + * apple.prebuild are the iOS project's and are not run for the Mac build); `--no-prebuild` skips it. + */ + macos?: { listing?: string; reviewNotes?: string; gameCenter?: boolean; noIap?: boolean; entitlements?: string; prebuild?: string }; /** "Header and Search Results" creative assets (`storebound apple creative-assets`, iOS / iPadOS 27+). Files under * //creative/: header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9), search.{...} (3:2, 1920x1280 to * 3840x2560) or universal.png (5244x2950) for both. `header` / `search` false skip that slot; `universal` is a @@ -706,6 +734,9 @@ export interface ShipConfig { build?: AppleBuildConfig; /** Shell command `apple build` runs first in the project root; overrides build.prebuild for the App Store. */ prebuild?: string; + /** `apple build --platform macos`: shell command run on the built .app before productbuild ({file} = the .app path); + * a nonzero exit stops the build, nothing is packaged. E.g. a project's own check that a build-time value is in the binary. */ + macAppCheck?: string; /** Promoted in-app purchases on the product page, in order (`storebound apple promoted`; at most 20). */ promoted?: PromotedConfig; /** Custom product pages (`storebound apple cpp`; at most 70). */ @@ -1009,6 +1040,7 @@ export function validateExtras(cfg: Partial): string[] { if (cfg.build !== undefined && (typeof cfg.build !== 'object' || cfg.build === null)) e.push('build: { prebuild?: "" }'); if (badCmd(cfg.build?.prebuild)) e.push('build.prebuild: a shell command run in the project root before apple build / play build, e.g. "npm run build && npx cap sync"'); if (badCmd(cfg.apple?.prebuild)) e.push('apple.prebuild: a shell command (overrides build.prebuild for apple build)'); + if (badCmd(cfg.apple?.macAppCheck)) e.push('apple.macAppCheck: a shell command run on the built Mac .app ({file} = its path), e.g. "node tools/check-app.mjs {file}"'); if (badCmd(cfg.play?.prebuild)) e.push('play.prebuild: a shell command (overrides build.prebuild for play build)'); if (cfg.apple?.teamId !== undefined &&!/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)'); if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/'); @@ -1017,6 +1049,14 @@ export function validateExtras(cfg: Partial): string[] { e.push('play.developerAccount: the developer account name as Play Console shows it (1-100 characters)'); if (cfg.play?.name !== undefined && !(typeof cfg.play.name === 'string' && cfg.play.name.trim() && [...cfg.play.name].length <= 30)) e.push('play.name: 1-30 characters (Play app name)'); if (cfg.play?.free !== undefined && typeof cfg.play.free !== 'boolean') e.push('play.free: true (free) | false (paid)'); + const mo = cfg.apple?.macos; + if (mo !== undefined) { + if (typeof mo !== 'object' || mo === null || Array.isArray(mo)) e.push('apple.macos: { listing?: "store/appstore-macos.md", reviewNotes?: "...", gameCenter?: true, noIap?: true, entitlements?: "src-tauri/Entitlements.appstore.plist", prebuild?: "" }'); + else { + for (const k of ['listing', 'reviewNotes', 'entitlements', 'prebuild'] as const) if (mo[k] !== undefined && (typeof mo[k] !== 'string' || !mo[k]!.trim())) e.push(`apple.macos.${k}: a non-empty string${k === 'listing' ? ' (path of the macOS App Store listing file)' : k === 'entitlements' ? ' (path of the Mac App Store entitlements plist)' : ''}`); + for (const k of ['gameCenter', 'noIap'] as const) if (mo[k] !== undefined && typeof mo[k] !== 'boolean') e.push(`apple.macos.${k}: true | false`); + } + } const ad = cfg.apple?.adhoc; if (ad?.method !== undefined && ad.method !== 'release-testing' && ad.method !== 'ad-hoc') e.push('apple.adhoc.method: release-testing | ad-hoc'); const ppp = cfg.prices?.ppp; @@ -1095,6 +1135,23 @@ function validateIarcDetails(d: IarcDetails | undefined): string[] { } /** Platforms of the app (app identity): app.platforms, else from the store sections. */ +/** True for the macOS platform in any spelling: "macos", "mac_os" (API name MAC_OS), "mac". Pure. */ +export const isMacosPlatform = (p: string | undefined | null): boolean => !!p && /^mac(_?os)?$/i.test(p.replace(/[\s-]/g, '')); + +/** The App Store listing file (as written in the config, relative) for a platform: apple.macos.listing for macOS when + * set, else apple.listing, else store/appstore.md. Pure. */ +export function listingPathFor(config: Pick, platform?: string | null): string { + const mac = isMacosPlatform(platform) ? config.apple?.macos?.listing?.trim() : ''; + return mac || config.apple?.listing || 'store/appstore.md'; +} + +/** The App Review notes text of the config for a platform: apple.macos.reviewNotes for macOS when set, else + * apple.info.reviewNotes. Pure; reviewAccess and account deletion lines are added by reviewNotes() in lib/review-details. */ +export function reviewNotesFor(config: Pick, platform?: string | null): string | undefined { + const mac = isMacosPlatform(platform) ? config.apple?.macos?.reviewNotes : undefined; + return mac?.trim() ? mac : config.apple?.info?.reviewNotes; +} + /** App Store name limit (characters). */ export const APPLE_NAME_MAX = 30; @@ -1104,6 +1161,14 @@ export const appleName = (c: Pick): string => c.app export const platformsOf = (c: Pick): Platform[] => c.app.platforms ?? [...(c.apple ? ['ios' as const] : []), ...(c.play ? ['android' as const] : [])]; +/** What a macOS submission leaves out per apple.macos (noIap: the in-app purchases; no gameCenter: the version's Game + * Center switch), with why; nulls for every other platform. Pure. */ +export function macosSubmitSkips(c: Pick, platform: string): { iaps: string | null; gameCenter: string | null } { + if (platform !== 'MAC_OS') return { iaps: null, gameCenter: null }; + return { iaps: c.apple?.macos?.noIap ? 'apple.macos.noIap (the Mac app sells nothing)' : null, + gameCenter: c.apple?.macos?.gameCenter ? null : 'the Mac app has no Game Center (apple.macos.gameCenter is not true)' }; +} + /** Is a catalog item (product / subscription) sold on this store? Pure. */ export const sellsOn = (c: Pick, item: { platforms?: Platform[] }, store: 'apple' | 'play'): boolean => { const p: Platform = store === 'apple' ? 'ios' : 'android'; @@ -1189,15 +1254,18 @@ export function validateConfig(c: unknown): string[] { if (cfg.play?.billingPrograms !== undefined && !(Array.isArray(cfg.play.billingPrograms) && cfg.play.billingPrograms.every(b => (PLAY_BILLING_PROGRAMS as readonly string[]).includes(b)))) e.push(`play.billingPrograms: a list of ${PLAY_BILLING_PROGRAMS.join(' | ')}`); if (cfg.apple) { - if (!ID_RE.test(cfg.apple.bundleId ?? '')) e.push('apple.bundleId: not a bundle id (com.example.game)'); + // a store-only config for reads (analytics, reports) may name the app by apple.appId alone: bundleId null / absent + const idOnly = cfg.apple.bundleId == null && /^\d+$/.test(cfg.apple.appId ?? ''); + if (!idOnly && !ID_RE.test(cfg.apple.bundleId ?? '')) e.push('apple.bundleId: not a bundle id (com.example.game)'); const an = cfg.apple.name; if (an !== undefined && (typeof an !== 'string' || !an.trim())) e.push('apple.name: the App Store name (text), or leave it out to use app.name'); else if (an !== undefined && [...an].length > APPLE_NAME_MAX) e.push(`apple.name: ${[...an].length} characters > ${APPLE_NAME_MAX} (App Store limit)`); if (cfg.apple.appId && !/^\d+$/.test(cfg.apple.appId)) e.push(`apple.appId: numeric Apple ID of the app (${consoleLink('asc.app-info')})`); + e.push(...validateReleaseType(cfg.apple)); const enc = cfg.apple.usesNonExemptEncryption; if (enc !== undefined && typeof enc !== 'boolean') e.push('apple.usesNonExemptEncryption: true | false'); if (cfg.apple.screenshotTypes && !cfg.apple.screenshotTypes.every(t => /^APP_[A-Z0-9_]+$/.test(t))) e.push('apple.screenshotTypes: display types like APP_IPHONE_67'); - e.push(...validateTestflight(cfg.apple.testflight)); + e.push(...validateTestflight(cfg.apple.testflight, cfg.languages ?? [])); e.push(...validateSigning(cfg.apple.signing), ...validateAppleBuild(cfg.apple.build)); const catalogIds = new Set([...(cfg.products ?? []).map(p => p.id), ...(cfg.subscriptions ?? []).flatMap(g => (g.subscriptions ?? []).map(x => x.id))]); e.push(...validatePromoted(cfg.apple.promoted, catalogIds)); diff --git a/cli/lib/console-answers.ts b/cli/lib/console-answers.ts index 8294abb..036fc47 100644 --- a/cli/lib/console-answers.ts +++ b/cli/lib/console-answers.ts @@ -25,11 +25,12 @@ import type { ProjectSignals } from './project-signals'; export const FORM_IDS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] as const; export type FormId = typeof FORM_IDS[number]; -/** Forms with a console plan only (the agent drives them; no scripted fill): Play Store settings (the App category the +/** Forms with a console plan only (the agent drives them; no scripted fill): apple-app-group (the developer portal App Group + * assignment the API cannot do, cli/lib/console-apple-group-plan.ts), Play Store settings (the App category the * API cannot set), the App content overview and its declarations without a fill form (cli/lib/console-play-plans.ts). */ export const PLAN_ONLY_FORM_IDS = ['play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details', 'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', - 'play-games-services', 'play-publish-changes'] as const; + 'play-games-services', 'play-publish-changes', 'apple-app-group'] as const; export type PlanOnlyFormId = typeof PLAN_ONLY_FORM_IDS[number]; /** Every form `console plan` / `console verify` know. */ export const PLAN_FORM_IDS = [...FORM_IDS, ...PLAN_ONLY_FORM_IDS] as const; diff --git a/cli/lib/console-apple-group-plan.ts b/cli/lib/console-apple-group-plan.ts new file mode 100644 index 0000000..c54d404 --- /dev/null +++ b/cli/lib/console-apple-group-plan.ts @@ -0,0 +1,84 @@ +// `storebound console plan apple-app-group`: the App Store Connect API can switch the APP_GROUPS capability on for a +// bundle id but cannot assign a group to it, so an app extension (a packet-tunnel extension, a share extension, a +// widget) whose entitlements list `com.apple.security.application-groups` gets a profile with an empty group list until +// the group is ticked in the developer portal. Agent-first like the other plans: the agent drives the signed-in +// developer.apple.com page with its own browser tool, presses Save itself (it is a capability edit on the owner's own +// App ID, not a store submission: finalByAgent), then `storebound apple signing ensure --refresh-profile` and +// `storebound console verify apple-app-group` (read-only, API). Pure. +// Modeled on Apple's help ("Configure App Groups", "Register an App Group"), not yet recorded on a real portal page: the +// labels are where to start, the agent adapts. +import { bundleIdPortalUrl, PORTAL_IDENTIFIERS_URL } from './targets'; +import { NEVER_PRESS } from './console-forms'; +import type { ConsolePlan, PlanAnswer, PlanIds, PlanStep } from './console-plan'; + +export const PORTAL_REMOVE = ['Remove', 'Remove App ID', 'Delete', 'Delete identifier', 'Revoke', 'Revoke certificate', 'Download']; +const CHECK = 'document.querySelector("main")?.innerText ?? document.body.innerText'; + +export function appleAppGroupPlan(p: PlanIds): ConsolePlan { + const targets = p.appGroupTargets ?? []; + const one = targets.length === 1 ? targets[0]! : null; + const answers: PlanAnswer[] = []; + const steps: PlanStep[] = []; + const add = (s: Omit): void => { steps.push({ n: steps.length + 1, ...s }); }; + const a = (x: PlanAnswer): void => { answers.push(x); }; + + if (!targets.length) { + a({ id: 'targets', question: 'Which bundle id needs which App Group(s)?', value: null, text: '(none found)', status: 'ask-owner', source: 'entitlements of the Xcode targets', + note: 'No target of the project lists com.apple.security.application-groups. Pass --bundle-id --groups group.a,group.b, or list the extension in apple.signing.extensions with appGroups.' }); + } + for (const t of targets) { + a({ id: `${t.bundleId}/capability`, question: `${t.bundleId}: App Groups capability`, value: true, text: 'on', status: 'certain', source: t.source ?? 'entitlements', control: 'checkbox', label: 'App Groups', action: 'tick', where: 'Capabilities list of the App ID page' }); + for (const g of t.groups) a({ id: `${t.bundleId}/${g}`, question: `${t.bundleId}: App Group`, value: g, text: g, status: 'certain', source: t.source ?? 'entitlements', control: 'checkbox', label: g, action: 'tick', where: 'the App Group assignment dialog (App Groups > Configure)' }); + } + + const start = one?.resourceId + ? { url: bundleIdPortalUrl(one.resourceId), deepLink: true, reach: [] as string[] } + : { url: PORTAL_IDENTIFIERS_URL, deepLink: false, reach: ['Certificates, Identifiers & Profiles > Identifiers', targets.length ? `search the bundle id (${targets.map(t => t.bundleId).join(', ')}) and open it` : 'search the bundle id and open it', 'with --lookup the plan gets the deep link (https://developer.apple.com/account/resources/identifiers/bundleId/edit/)'] }; + + add({ text: `Open ${start.url} in the signed-in browser (never sign in, never type the account password: if the page asks to sign in, tell the owner).${start.deepLink ? '' : ' Identifiers > search the bundle id > open it.'}` }); + for (const t of targets) { + const url = t.resourceId ? bundleIdPortalUrl(t.resourceId) : null; + const gs = t.groups.join(', '); + add({ text: `${t.bundleId}${url ? ` (${url})` : ''}: on the "Edit Your App ID Configuration" page, find App Groups in the Capabilities list and tick its checkbox if it is not ticked.`, answers: [`${t.bundleId}/capability`] }); + add({ text: `${t.bundleId}: press "Configure" next to App Groups. In the dialog that lists the registered App Groups tick ${gs} (exact identifiers; tick nothing else), then press "Continue". A group that is not listed must be registered first: Identifiers > the blue + > App Groups > Continue > Description and Identifier (the group id of the sheet) > Continue > Register; then reopen this page.`, click: ['Configure', 'Continue'], answers: t.groups.map(g => `${t.bundleId}/${g}`) }); + add({ text: `${t.bundleId}: press "Save" at the top right. If a dialog says the provisioning profiles of this App ID become invalid, press "Confirm" (a new profile is made by \`storebound apple signing ensure --refresh-profile\`). Read the page back: App Groups shows the configured count.`, click: ['Save', 'Confirm'] }); + } + add({ text: `Then run \`storebound apple signing ensure --refresh-profile\` (a new profile that carries the group), and \`storebound console verify apple-app-group${one ? ` --bundle-id ${one.bundleId}` : ''}\` (read-only: reads the App ID and the newest profile through the API and checks every group).` }); + + return { + form: 'apple-app-group', store: 'apple', title: 'Apple Developer portal: assign App Groups to an App ID (the API cannot)', + ownerBatch: [], start, steps, answers, + askOwner: answers.filter(x => x.status !== 'certain').map(x => `${x.question}: ask the owner${x.note ? ` (${x.note})` : ''}`), + buttons: { + neverPress: [...new Set([...PORTAL_REMOVE, 'Revoke', 'Account', 'Certificates', 'Keys', ...NEVER_PRESS])], + final: 'Save', finalByAgent: true, + allowed: [ + { button: 'Configure', class: 'navigation', where: 'App Groups row: opens the group assignment dialog' }, + { button: 'Continue', class: 'navigation', where: 'the group assignment dialog and the App Group registration form' }, + { button: 'Register', class: 'portal-register', where: 'App Groups registration form only: registers the group id of the answer sheet (a new group identifier, nothing public)' }, + { button: 'Save', class: 'portal-save', where: 'top right of the App ID page: saves the capability edit (the agent presses it; not a store submission)' }, + { button: 'Confirm', class: 'portal-save', where: 'the dialog that warns the profiles become invalid after Save' }, + ], + rule: 'The agent presses everything it needs here, including Save (a capability edit on the owner\'s own App ID; no store submission, nothing public). Never press Remove / Delete / Revoke, never untick or edit another capability, never touch Certificates, Keys or Account pages; stop and tell the owner if the page asks to sign in.', + }, + uiFacts: [ + 'Modeled on Apple\'s help pages (not recorded on a real portal page yet): Certificates, Identifiers & Profiles > Identifiers > the App ID > Capabilities list > App Groups (checkbox) > Configure > the group list > Continue > Save (top right) > Confirm.', + 'The App Store Connect API can enable the APP_GROUPS capability (POST /v1/bundleIdCapabilities) but has no field that assigns a group: an extension\'s profile has an empty application-groups list until the group is ticked here, and the archive then fails at codesign.', + 'Saving the App ID invalidates its provisioning profiles; manual signing needs `storebound apple signing ensure --refresh-profile`, automatic signing lets Xcode renew its managed profile at archive time.', + 'The deep link uses the bundle id\'s App Store Connect API resource id: https://developer.apple.com/account/resources/identifiers/bundleId/edit/ (`console plan apple-app-group --bundle-id --lookup` reads it).', + 'Group identifiers start with group. and are registered once per team under Identifiers > App Groups.', + ], + driving: [ + 'Use your own browser tool (Claude in Chrome with the owner\'s signed-in session). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps, but never press anything in buttons.neverPress.', + 'Tick boxes by clicking the checkbox, then read its checked state back. After Save read the App ID page again.', + 'Press Save yourself (buttons.finalByAgent): it is not a store submission. Never sign in, never type passwords or 2FA codes.', + ], + inPageLoop: '', + verify: { + command: `storebound console verify apple-app-group${one ? ` --bundle-id ${one.bundleId}` : ''}`, source: 'api', capture: CHECK, + expect: ['Every App Group of the answer sheet is on the App ID\'s newest readable profile (re-download after `storebound apple signing ensure --refresh-profile`) or named by the App Groups capability settings.', 'Exit code 1 while a group is missing or cannot be proven.'], + }, + fillable: true, + guidance: ['Open: developer.apple.com > Account > Certificates, Identifiers & Profiles > Identifiers > the App ID'], + }; +} diff --git a/cli/lib/console-plan.ts b/cli/lib/console-plan.ts index ff11b8c..bd0142b 100644 --- a/cli/lib/console-plan.ts +++ b/cli/lib/console-plan.ts @@ -12,6 +12,7 @@ import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, i import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_FULL_ACCESS_BOX, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SAVE_EACH_PAGE, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans'; import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, PLAY_NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms'; import { PLAY_CREATE_CAPTURE } from './console-verify'; +import { appleAppGroupPlan } from './console-apple-group-plan'; import { APPLE_PURPOSES, IARC_DIGITAL_GOODS, IARC_DIGITAL_PURCHASES } from './console-answers'; export type PlanStatus = 'certain' | 'ask-owner' | 'owner-check' | 'agreement'; @@ -87,6 +88,8 @@ export interface ConsolePlan { allowed: { button: string; class: string; where: string }[]; /** The rule in one line. */ rule: string; + /** true: the final button is the agent's own (a portal capability edit such as Save on an App ID, not a store submission or a public change). */ + finalByAgent?: boolean; }; /** Known UI facts of this form (verified ones say so). */ uiFacts: string[]; @@ -110,6 +113,8 @@ export interface PlanIds { signals?: ProjectSignals; /** play-publish-changes: the production releases ready to publish per the API (null/undefined: not read). */ readyChanges?: { track: string; version: string | null; build: string | null; rollout: string }[] | null; + /** apple-app-group: the targets whose entitlements list App Groups (their bundle id, groups and, with --lookup, the App Store Connect resource id for the portal deep link). */ + appGroupTargets?: { bundleId: string; name?: string; groups: string[]; resourceId?: string | null; source?: string }[]; } const ASC = 'https://appstoreconnect.apple.com'; @@ -538,6 +543,7 @@ export interface PlanOpts { all?: boolean } export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}, o: PlanOpts = {}): ConsolePlan { const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config; if (form === 'play-app-content') return appContentPlan(cfg, p, o.all); + if (form === 'apple-app-group') return appleAppGroupPlan(p); if (isPlanOnly(form)) { const part = playOnlyPart(form, cfg, p); return { @@ -583,7 +589,7 @@ export function planText(pl: ConsolePlan): string { L.push(` ${tag} ${a.question}: ${a.text}${a.label ? ` -> ${a.action ?? ''} "${a.label}"${a.curlyQuotes ? ' (curly quotes)' : ''}` : ''}${a.where ? ` @ ${a.where}` : ''}`); if (a.status !== 'certain' && a.note) L.push(` ${a.note}`); } - L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (the owner's): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`), + L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (${pl.buttons.finalByAgent ? "the agent's own, not a store submission" : "the owner's"}): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`), '', 'In-page helper (plan.inPageLoop: paste it once per page in one short call, then window.__sb.run([...]) and poll window.__log):', ...pl.inPageLoop.split('\n').map(l => ` ${l}`), '', `Verify: ${pl.verify.command}`, ...pl.verify.expect.map(e => ` - ${e}`)); if (pl.askOwner.length) L.push('', 'Ask the owner first:', ...pl.askOwner.map(a => ` - ${a}`)); diff --git a/cli/lib/console-play-plans.ts b/cli/lib/console-play-plans.ts index 51978b7..9fe47ac 100644 --- a/cli/lib/console-play-plans.ts +++ b/cli/lib/console-play-plans.ts @@ -534,7 +534,7 @@ function publishChanges(config: ShipConfig, p: PlanIds): Part { }; } -export function playOnlyPart(form: Exclude, config: ShipConfig, p: PlanIds): Part { +export function playOnlyPart(form: Exclude, config: ShipConfig, p: PlanIds): Part { switch (form) { case 'play-production-countries': return productionCountries(config, p); case 'play-production-release': return productionRelease(config, p); diff --git a/cli/lib/console-verify.ts b/cli/lib/console-verify.ts index 730997d..5db26ec 100644 --- a/cli/lib/console-verify.ts +++ b/cli/lib/console-verify.ts @@ -19,6 +19,7 @@ import { APPLE_PRIVACY_TYPES } from './app-privacy'; import { typeLabels } from './console-forms'; import { playAppIdInText } from './play-app-id'; import { APPLE_PURPOSES } from './console-answers'; +import type { AppGroupGap } from './targets'; export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner'; export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string } @@ -360,7 +361,7 @@ export function parseStates(raw: string): { states: InputState[]; text: string } * the privacy policy URL) and option labels must appear; the App category's "Not selected" is a mismatch. Yes / No * radios cannot be told apart in page text: reported, never counted as a match. */ -export function verifyPlayDeclaration(form: Exclude, config: ShipConfig, text: string): VerifyResult { +export function verifyPlayDeclaration(form: Exclude, config: ShipConfig, text: string): VerifyResult { const part = playOnlyPart(form, config, {}); const st = parseStates(text); const blob = fold(st ? st.text : text); @@ -444,6 +445,27 @@ export function verifyPublishChangesApi(ready: { track: string; version: string ...(ready.undetermined ? ['A release counts as ready only when the public store page shows an OLDER version; a 404, failed read or same version is never ready. Check Publishing overview ("Last published on ...") or pass the page text with --from-text -.'] : [])]); } +/** + * apple-app-group (read-only, API): one row per (bundle id, App Group). match: the group is on the newest readable + * profile or named by the capability settings; mismatch: the evidence shows it missing; not-found: nothing readable + * proves it (after the portal Save the old profiles are invalid: run `storebound apple signing ensure --refresh-profile`, + * then verify again). ok only when every row matches. + */ +export function verifyAppGroupsApi(targets: { bundleId: string; appGroups: string[] }[], verdicts: { gaps: AppGroupGap[]; unproven: AppGroupGap[]; ok: string[] }): VerifyResult { + const rows: VerifyRow[] = []; + for (const t of targets.filter(x => x.appGroups.length)) { + const gap = verdicts.gaps.find(g => g.bundleId === t.bundleId), un = verdicts.unproven.find(g => g.bundleId === t.bundleId); + for (const g of t.appGroups) { + const item = `${t.bundleId}: ${g}`; + if (gap?.groups.includes(g)) rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'missing', status: 'mismatch', ...(gap.why ? { note: gap.why } : {}) }); + else if (un?.groups.includes(g)) rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'cannot be proven', status: 'not-found', note: 'no ACTIVE profile shows it: run storebound apple signing ensure --refresh-profile, then verify again' }); + else rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'present', status: 'match' }); + } + } + const r = result('apple-app-group', 'api', rows, ['Reads the App Store Connect API: the App Groups capability and the newest readable provisioning profile of each bundle id.']); + return rows.some(x => x.status === 'not-found') ? { ...r, ok: false } : r; +} + /** Page-text verification of any form that has one. */ export function verifyText(form: PlanFormId, config: ShipConfig, text: string): VerifyResult { if (form === 'play-app-content') return verifyAppContent(text); @@ -460,6 +482,7 @@ export function verifyText(form: PlanFormId, config: ShipConfig, text: string): case 'play-target-audience': return verifyTargetAudience(config, text); case 'apple-app-record': return verifyAppRecordText(config, text); case 'apple-age-rating': throw new Error('console verify apple-age-rating reads the App Store Connect API (no --from-text)'); + case 'apple-app-group': throw new Error('console verify apple-app-group reads the App Store Connect API (no --from-text)'); } } diff --git a/cli/lib/credentials.ts b/cli/lib/credentials.ts index 186a8fe..b3fe947 100644 --- a/cli/lib/credentials.ts +++ b/cli/lib/credentials.ts @@ -20,7 +20,9 @@ // The demo account for App Review can also come from env: REVIEW_DEMO_USER, REVIEW_DEMO_PASSWORD. // 4. the default profile (none named): store keys only // 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_.p8 (where Xcode / altool keep it) -// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? }; +// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "reportsBucket"?, "label"? }; +// with a credential broker (cli/lib/broker.ts) Play needs no key file: "PLAY_SERVICE_ACCOUNT_EMAIL" names the account +// the broker mints tokens for, and "broker": false keeps a profile on its local key. // key paths relative to the credentials folder (`accounts import` puts the key files in /, mode 0600), or env // STOREBOUND_PROFILE__ (NAME upper case, other characters as _). storebound.workspace.json names profiles too. import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -44,7 +46,7 @@ function fileStore(root: string): Store { export function credentialValue(root: string, name: string): string | undefined { return value(root, name); } /** The store credential names a profile holds (the same as the env names): per developer account, so the vendor number * for reports too; an app's Android upload keystore stays in the project. */ -export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID'] as const; +export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'PLAY_REPORTS_BUCKET'] as const; export const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; export const profileDir = (): string => process.env.STOREBOUND_CREDENTIALS_DIR || join(homedir(), '.storebound', 'credentials'); @@ -64,7 +66,9 @@ export function profileEnv(profile: string): Record { const out: Record = {}; for (const k of PROFILE_KEYS) { const env = process.env[profileEnvName(profile, k)]; - const v = env || (typeof stored[k] === 'string' ? stored[k] as string : ''); + // PLAY_REPORTS_BUCKET is also the profile field `reportsBucket` (a bucket name, not a secret) + const own = stored[k] ?? (k === 'PLAY_REPORTS_BUCKET' ? stored.reportsBucket : undefined); + const v = env || (typeof own === 'string' ? own : ''); if (!v) continue; // a path in the file: relative to the profile folder (inline JSON / PEM stays as it is) const isPath = !env && !/^\s*\{/.test(v) && !/BEGIN PRIVATE KEY/.test(v) && (k === 'PLAY_SERVICE_ACCOUNT' || k === 'ASC_P8'); @@ -158,7 +162,7 @@ export function isPlaceholder(v: string, o: { example?: boolean } = {}): boolean } /** Credential names storebound reads (env, .storebound/credentials.json, a profile), for status reports. */ -export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID', +export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID', 'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY', 'APPLE_ADS_ACCOUNT_ID', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEYSTORE_PASSWORD', 'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD'] as const; /** Where each known credential stands: set, missing, or placeholder (a template value, treated as missing). Never @@ -206,7 +210,7 @@ export function parseServiceAccount(text: string): ServiceAccount { export function playServiceAccount(root: string): ServiceAccount { const v = value(root, 'PLAY_SERVICE_ACCOUNT'); - if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key; `storebound accounts` lists the profiles'); + if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key, or a credential broker in ~/.storebound/broker.json; `storebound accounts` lists the profiles'); return parseServiceAccount(inline(v, /^\s*\{/, root)); } @@ -344,6 +348,16 @@ export function ascVendorNumber(root: string): string | null { return v && /^\d{5,12}$/.test(v.trim()) ? v.trim() : null; } +/** The Play reports bucket (analytics statistics, sales / earnings reports): play.reportsBucket of the config, else env + * PLAY_REPORTS_BUCKET, else the credential profile's `reportsBucket` (a store-only config has no play.reportsBucket). + * Null when none is set; a value that is not a Play reports bucket throws (a bucket name is not a secret). */ +export function playReportsBucket(root: string, fromConfig?: string): string | null { + const v = (fromConfig ?? value(root, 'PLAY_REPORTS_BUCKET') ?? '').trim(); + if (!v) return null; + if (!/^(gs:\/\/)?pubsite_prod(_rev)?_\d+\/?$/.test(v)) throw new Error(`reports bucket "${v}": the Play reports bucket, e.g. "pubsite_prod_rev_" (play.reportsBucket, env PLAY_REPORTS_BUCKET or the profile field reportsBucket)`); + return v; +} + /** Credential paths that do not exist (a machine move left another computer's absolute paths) and an upload keystore * the stored password / alias do not open: each names the field. `keytool` runs only when it is on PATH. */ export function credentialFileIssues(root: string, run: (argv: string[]) => { ok: boolean; out: string } | null = keytool): string[] { diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 58a2582..3c86210 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -148,6 +148,8 @@ export interface ProjectSummary { /** Play production settings from the config: play.managedPublishing (null: not set, Play's default off), the number * of play.countries (null: all countries / regions) and the App category (play.category or the App Store match). */ playRelease?: { managedPublishing: boolean | null; countries: number | null; category: string | null } | null; + /** The games section of the config (Game Center / Play Games Services and the item counts), null without one. */ + games?: { gameCenter: boolean; playGames: boolean; leaderboards: number; achievements: number } | null; } // ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ---- @@ -313,6 +315,22 @@ export interface AccountCheck { /** What to do, in words. */ fix: string | null; docs: string | null; + /** Play: where the tokens come from, the credential broker or the local key file (no values). */ + source?: 'broker' | 'file' | null; + /** Play with a credential broker: the grants and APIs it lists per Play app of this machine. */ + grants?: BrokerGrant[]; +} + +/** One Play app's access as the credential broker lists it (GET /api/google/play//storebound). */ +export interface BrokerGrant { + packageName: string; + /** false: the package is not registered on the broker (no data; not a missing access). */ + registered: boolean; + /** Play permissions of the service account (developer-level ones included); null when the broker has none. */ + grants: string[] | null; + enabledApis: string[] | null; + /** Permissions / APIs storebound's commands need that are not there. */ + missing: string[]; } /** diff --git a/cli/lib/device-families.ts b/cli/lib/device-families.ts new file mode 100644 index 0000000..0630f22 --- /dev/null +++ b/cli/lib/device-families.ts @@ -0,0 +1,111 @@ +// Device families of an iOS app: what the last live version of the app on the App Store supports, against what the +// project builds (TARGETED_DEVICE_FAMILY in the Xcode project) and what the config says (apple.ipad). +// Apple refuses an upload that drops a device family the previous version supported: ITMS-90101 "This bundle does not +// support one or more of the devices supported by the previous app version", after a full archive (seen live: an app +// whose live version ran on iPad, built with TARGETED_DEVICE_FAMILY = 1). So `validate` and `apple build` compare first. +// Pure helpers; the store reads are in cli/apple/device-families.ts. +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface Families { iphone: boolean; ipad: boolean } + +/** Families in TARGETED_DEVICE_FAMILY: 1 iPhone/iPod, 2 iPad (3 tvOS, 4 watchOS, 6 Mac Catalyst, 7 Vision). Pure. */ +export function familiesOfList(list: number[]): Families { return { iphone: list.includes(1), ipad: list.includes(2) }; } + +/** The families a store signal names: build bundle deviceProtocols, file-size device models, screenshot display types. Pure. */ +export function familiesOfStrings(values: string[]): Families { + const t = values.join(' '); + return { iphone: /iphone|ipod/i.test(t), ipad: /ipad/i.test(t) }; +} + +export interface LiveFamilies { version: string; state: string; families: Families; evidence: string[] } + +/** Merges the signals of one live version: any signal naming iPad means the version supports iPad. null when no signal + * names any family (unknown, never "iPhone only"). Pure. */ +export function liveFamilies(version: string, state: string, signals: { name: string; values: string[] }[]): LiveFamilies | null { + const evidence: string[] = []; + const families: Families = { iphone: false, ipad: false }; + for (const s of signals) { + const f = familiesOfStrings(s.values); + if (!f.iphone && !f.ipad) continue; + families.iphone ||= f.iphone; + families.ipad ||= f.ipad; + evidence.push(`${s.name}: ${[f.iphone && 'iPhone', f.ipad && 'iPad'].filter(Boolean).join(' + ')}`); + } + return evidence.length ? { version, state, families, evidence } : null; +} + +export interface ProjectFamilies { file: string; list: number[]; families: Families; configurations: string[] } + +/** The Release build configurations of a pbxproj with their TARGETED_DEVICE_FAMILY: the ones of the app's bundle id, + * else every Release configuration that is not an extension (bundle id below the app's). Pure. */ +export function pbxFamilies(text: string, bundleId: string, file = 'project.pbxproj'): ProjectFamilies | null { + const blocks: { name: string; bundle: string | null; family: string | null; ext: boolean }[] = []; + const re = /isa = XCBuildConfiguration;[^{]*?buildSettings = \{([\s\S]*?)\};\s*name = ("[^"]*"|[\w.-]+);/g; + for (let m: RegExpExecArray | null; (m = re.exec(text));) { + const body = m[1]!; + const fam = /TARGETED_DEVICE_FAMILY = ("?)([\d, ]+)\1;/.exec(body)?.[2] ?? null; + const bid = /PRODUCT_BUNDLE_IDENTIFIER = "?([^";\s]+)"?;/.exec(body)?.[1] ?? null; + blocks.push({ name: m[2]!.replace(/"/g, ''), bundle: bid, family: fam, ext: /WRAPPER_EXTENSION = appex|APPLICATION_EXTENSION_API_ONLY = YES/.test(body) || (bid !== null && bid.startsWith(`${bundleId}.`)) }); + } + const release = blocks.filter(b => b.name === 'Release' && b.family !== null); + let pick = release.filter(b => b.bundle === bundleId); + if (!pick.length) pick = release.filter(b => !b.ext); + if (!pick.length) return null; + const list = [...new Set(pick.flatMap(b => b.family!.split(',').map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0)))].sort((a, b) => a - b); + return { file, list, families: familiesOfList(list), configurations: pick.map(b => b.family!.replace(/\s/g, '')) }; +} + +const SKIP = new Set(['node_modules', 'Pods', '.git', 'build', 'DerivedData', 'Carthage', '.build', 'dist', 'vendor']); + +/** Every project.pbxproj under root (depth <= 5), root-relative with forward slashes. */ +export function findPbxprojs(root: string, depth = 5): string[] { + const out: string[] = []; + const walk = (dir: string, rel: string, d: number): void => { + if (d > depth || !existsSync(dir)) return; + let names: import('node:fs').Dirent[]; + try { names = readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of names) { + if (!e.isDirectory() || SKIP.has(e.name)) continue; + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.name.endsWith('.xcodeproj')) { if (existsSync(join(dir, e.name, 'project.pbxproj'))) out.push(`${r}/project.pbxproj`); continue; } + walk(join(dir, e.name), r, d + 1); + } + }; + walk(root, '', 0); + return out; +} + +/** The project's device families, read from the Xcode project (apple.build.project when set, else the first project + * that has the app's configurations). null when the project is not on this machine or sets no families. */ +export function projectFamilies(root: string, bundleId: string, project?: string): ProjectFamilies | null { + const files = project && project.endsWith('.xcodeproj') ? [`${project.replace(/\\/g, '/')}/project.pbxproj`] : findPbxprojs(root); + for (const f of files) { + try { + const r = pbxFamilies(readFileSync(join(root, f), 'utf8'), bundleId, f); + if (r) return r; + } catch { /* unreadable: next */ } + } + return null; +} + +export interface FamilyVerdict { level: 'ok' | 'blocker' | 'warn'; msg: string; fix?: string } + +/** Compares the live version, the project and the config. Blocker: the live version supports iPad and the project does + * not (the upload is refused with ITMS-90101) or apple.ipad is not true (iPad screenshots and checks are off). Pure. */ +export function familyVerdicts(i: { live: LiveFamilies | null; project: ProjectFamilies | null; ipad: boolean | undefined }): FamilyVerdict[] { + const out: FamilyVerdict[] = []; + const { live, project } = i; + const fixProject = 'set TARGETED_DEVICE_FAMILY to "1,2" (Xcode: target > General > Supported Destinations: add iPad)'; + if (!live) { + out.push({ level: 'warn', msg: `device families of the live version not known${project ? '' : ' and no Xcode project found'}: if it supports iPad, an iPhone-only build is refused at upload (ITMS-90101)`, fix: 'storebound validate (reads the live version from App Store Connect)' }); + return out; + } + const liveText = `${[live.families.iphone && 'iPhone', live.families.ipad && 'iPad'].filter(Boolean).join(' + ')} (version ${live.version} ${live.state}; ${live.evidence.join('; ')})`; + if (live.families.ipad) { + if (project && !project.families.ipad) out.push({ level: 'blocker', msg: `the live version supports ${liveText}, but ${project.file} builds TARGETED_DEVICE_FAMILY = ${project.configurations.join(' / ')} (no iPad): Apple refuses the upload after the archive with ITMS-90101`, fix: `${fixProject}; and apple.ipad: true in the config` }); + if (i.ipad !== true) out.push({ level: 'blocker', msg: `the live version supports ${liveText}, but apple.ipad is not true in the config`, fix: 'set apple.ipad: true (iPad screenshots are then required) and TARGETED_DEVICE_FAMILY "1,2"' }); + if (!out.length) out.push({ level: 'ok', msg: `device families: live ${liveText}; project ${project ? `TARGETED_DEVICE_FAMILY ${project.configurations.join(' / ')}` : 'not read'}; apple.ipad true` }); + } else out.push({ level: 'ok', msg: `device families: live ${liveText}; nothing to keep` }); + return out; +} diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index c963dee..e11a6ba 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -26,13 +26,13 @@ export const TEMPLATE_TEXT: Record = { "profiles/subscription-app/listing.md": "# Example App · Google Play listing copy\n\nSource for `storebound play listing` (title, short + full description) and `storebound play release` (release notes).\nRules the parser follows:\n- every `## ...` section with a `` line is a listing; one section can serve several Play codes\n- under it, `### ...` headings that contain **title** (optional, defaults to `app.name`), **short**, **full** and\n **what's new**, each followed by one fenced block\n- limits (checked before anything is sent): title 30, short 80, full 4000, what's new 500 characters\nOnly claim what the app really does. No prices, no \"best / #1\", no other apps' names.\n\n---\n\n## English (en-US)\n\n\n### Play short description (max 80)\n```\nWhat the app does for people, in one plain line\n```\n\n### Play full description (max 4000)\n```\nFirst paragraph: the job the app does and why it is easier with it, in two or three sentences.\n\nFEATURES\n• Lists and notes that sync across your devices\n• Share a list with family or friends\n• Export your data or delete your account in Settings, any time\n\nFree to use with up to 3 lists. Example Pro unlocks unlimited lists, sharing and themes: yearly with a 1-week free trial, or monthly. Payment is charged to your store account; the subscription renews automatically unless cancelled at least 24 hours before the end of the period, and you can manage or cancel it in your account settings.\n\nTerms of Use: https://example.com/terms\nPrivacy Policy: https://example.com/privacy\n```\n\n### What's new (max 500)\n```\nFirst release. Thanks for trying it!\n```\n\n## Türkçe (tr-TR)\n\n\n### Play kısa açıklama · short (max 80)\n```\nUygulamanın insanlar için ne yaptığını anlatan sade bir cümle\n```\n\n### Play tam açıklama · full (max 4000)\n```\nİlk paragraf: uygulamanın gördüğü iş ve onunla neden daha kolay olduğu, iki üç cümlede.\n\nÖZELLİKLER\n• Tüm cihazlarında eşitlenen listeler ve notlar\n• Bir listeyi ailen ya da arkadaşlarınla paylaş\n• Verilerini dışa aktar ya da hesabını Ayarlar'dan istediğin an sil\n\n3 listeye kadar ücretsiz. Example Pro sınırsız liste, paylaşım ve tema açar: 1 hafta ücretsiz denemeli yıllık ya da aylık. Ödeme mağaza hesabınızdan alınır; abonelik, dönem bitmeden en az 24 saat önce iptal edilmezse otomatik yenilenir; hesap ayarlarınızdan yönetebilir ya da iptal edebilirsiniz.\n\nKullanım Koşulları: https://example.com/terms\nGizlilik Politikası: https://example.com/privacy\n```\n\n### Yenilikler · what's new (max 500)\n```\nİlk sürüm. Denediğin için teşekkürler!\n```\n", "profiles/subscription-app/storebound.config.ts": "// storebound config for one app (profile \"subscription-app\": accounts plus auto-renewable subscriptions, an\n// analytics SDK, no ads). Every answer below is a starting point: edit it to match your app. Credentials NEVER go\n// here (env vars or .storebound/credentials.json, gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'],\n\n app: {\n name: 'Example App',\n profile: 'subscription-app',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n termsUrl: 'https://example.com/terms', // Terms of Use (EULA): in the app and the metadata (Apple 3.1.2)\n supportUrl: 'https://example.com/support',\n marketingUrl: 'https://example.com',\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // Accounts (account-deletion skill). Remove this block if the app has no sign-up / sign-in at all.\n // Apple 5.1.1(v): deletion must start inside the app. Google Play: in the app AND on a public web page.\n accounts: {\n enabled: true,\n deletionUrl: 'https://example.com/delete-account', // public page, no login needed to request deletion\n inApp: 'Settings > Account > Delete account', // goes into the App Review notes\n // How people sign up: 'username' (a name picked in the app, no password, no email), 'username-password',\n // 'email-password', 'email-link', 'phone', 'oauth', 'other'. `storebound scan` proposes the account data and\n // dataSafety.accountCreation from it; only the 'email-*' methods mean an email address.\n signUp: ['email-password'],\n },\n\n // Sign-in for store reviewers (review-access skill). The demo account itself lives ONLY in\n // .storebound/credentials.json (\"reviewDemoAccount\") or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD.\n reviewAccess: {\n signInRequired: true, // false if every feature works without signing in\n instructions: 'Sign in with the demo account. Sample data is already there; nothing needs a second device.',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n ],\n\n play: {\n packageName: 'com.example.app',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n },\n\n apple: {\n bundleId: 'com.example.app',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n listing: 'store/appstore.md',\n // Export compliance (export-compliance skill): decide it, then uncomment. false ONLY if the app uses no encryption\n // beyond the OS (HTTPS through the system, standard sign-in).\n // usesNonExemptEncryption: false,\n // ipad: true,\n info: {\n categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' },\n ageRating: {}, // every content question NONE / false; change what applies (user-generated content, web access ...)\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Example App keeps lists and notes in sync across devices. Pro (yearly with a 1-week free trial, or monthly) unlocks unlimited lists, sharing and themes.',\n 'Subscriptions: Settings > Pro (or the paywall after the third list). Restore Purchases is on the paywall and in Settings > Pro.',\n 'Privacy policy and Terms of Use: on the paywall and in Settings > About.',\n // With a remote paywall or feature flags (storebound scan warns, 2.3.1) you may add a line like:\n // 'Paywall designs and the prices shown are tested remotely with ; what Pro unlocks does not change after review.',\n // Google Play has no review-notes field: put the same line in reviewAccess.instructions (Play App content >\n // App access, 'Any other information') when the app has a sign-in, and say in the full description what needs\n // a paid subscription (Play: Deceptive behavior, Payments).\n ].join('\\n\\n'),\n },\n },\n\n // Auto-renewable subscriptions (store-subscriptions skill): `storebound apple subscriptions`, `storebound play\n // subscriptions`. Apple: one group, levels rank the plans (1 = highest). Play: one subscription per entry with one\n // auto-renewing base plan. Prices: USD base, Apple equalizes from the USA, Play converts per region; overrides set a\n // region by hand. The disclosure (price per period, renewal, how to cancel, Terms + Privacy links) belongs on the\n // purchase screen and in the descriptions.\n subscriptions: [\n {\n name: 'Example Pro',\n listings: { en: { name: 'Example Pro' }, tr: { name: 'Example Pro' } },\n subscriptions: [\n {\n id: 'com.example.app.pro_yearly', period: 'P1Y', level: 1, usd: 29.99,\n listings: {\n en: { title: 'Pro yearly', description: 'Unlimited lists, sharing and themes.', benefits: ['Unlimited lists', 'Shared lists', 'All themes'] },\n tr: { title: 'Pro yıllık', description: 'Sınırsız liste, paylaşım ve tema.', benefits: ['Sınırsız liste', 'Paylaşılan listeler', 'Tüm temalar'] },\n },\n intro: { type: 'free', duration: 'P1W' }, // 1 week free trial for new subscribers\n reviewScreenshot: 'store/iap-review/paywall.png',\n },\n {\n id: 'com.example.app.pro_monthly', period: 'P1M', level: 2, usd: 3.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Pro monthly', description: 'Unlimited lists, sharing and themes.', benefits: ['Unlimited lists', 'Shared lists', 'All themes'] },\n tr: { title: 'Pro aylık', description: 'Sınırsız liste, paylaşım ve tema.', benefits: ['Sınırsız liste', 'Paylaşılan listeler', 'Tüm temalar'] },\n },\n reviewScreenshot: 'store/iap-review/paywall.png',\n },\n ],\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Typical app with accounts + an analytics SDK acting as\n // your processor (collected, not shared). Edit to match what your app and its SDKs really do.\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // Sign-up methods. Google does not publish these ids: compare with Play Console > App content > Data safety >\n // Export to CSV before the first import. The delete-account URL (PSL_ACCOUNT_DELETION_URL) comes from\n // accounts.deletionUrl; `storebound check` names any other row Play requires.\n accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],\n // Can people delete their data without deleting the account (a data-only page)? true needs partialDeletionUrl.\n // Play's \"can users request deletion?\" is answered yes by accounts.deletionUrl. Ask the owner.\n partialDeletion: false,\n data: {\n PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_ACCOUNT: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_GENERATED_CONTENT: { shared: false, collect: ['functionality'] },\n PSL_USER_INTERACTION: { shared: false, collect: ['analytics'] },\n PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: false, collect: ['analytics'] },\n PSL_DEVICE_ID: { shared: false, collect: ['analytics'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality'] }, // subscription status tied to the account\n },\n },\n\n renders: 'store/renders',\n\n shots: {\n style: 'app', // calm gradient, device frame, headline + subline (game: sparkles and badges)\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n // fonts: { title: 'Inter', body: 'Inter' }, // the app style default\n scenes: [\n { id: 'home', palette: 'mist', frames: [{ src: 'home.png' }] },\n { id: 'detail', palette: 'mint', frames: [{ src: 'detail.png' }] },\n { id: 'share', palette: 'sky', frames: [{ src: 'share.png' }] },\n { id: 'privacy', palette: 'slate', frames: [{ src: 'settings.png' }] },\n ],\n feature: { frame: 'home.png', palette: 'mist' },\n // Raw frames from a web build, the iOS Simulator (ios-sim) or an Android emulator (android-emu): sign in to a demo\n // account with realistic sample data, never real user data (store-screenshots skill).\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&demo=1',\n shots: {\n 'home': { path: '/', delayMs: 1000, device: 'both' },\n 'detail': { path: '/lists/1', delayMs: 1000, device: 'both' },\n 'share': { path: '/lists/1/share', delayMs: 1000, device: 'both' },\n 'settings': { path: '/settings', delayMs: 1000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png',\n foreground: 'store/icon/foreground.png',\n background: 'store/icon/background.png',\n out: 'store/icons',\n },\n};\n\nexport default config;\n", "shots-copy.json": "{\n \"en\": {\n \"hero\": { \"title\": [\"Tap. Match.\", \"Repeat!\"], \"badges\": [[\"100+ levels\", \"and more every update\"], [\"Play offline\", \"No account needed\"]] },\n \"play\": { \"eyebrow\": \"Play\", \"title\": [\"Easy to learn,\", \"hard to put down\"], \"sub\": \"One thumb, short rounds, big satisfying moments.\", \"badges\": [[\"Quick rounds\", \"Two minutes each\"], [\"Great!\"]] },\n \"levels\": { \"eyebrow\": \"Worlds\", \"title\": [\"Explore new\", \"worlds\"], \"sub\": \"A fresh look and new rules every ten levels.\", \"badges\": [[\"10 worlds\", \"A new one every 10 levels\"]] },\n \"stars\": { \"eyebrow\": \"Stars\", \"title\": [\"Play smart,\", \"grab 3 stars\"], \"sub\": \"Finish with moves to spare and collect every star.\", \"badges\": [[\"3 stars\"]] }\n },\n \"tr\": {\n \"hero\": { \"title\": [\"Dokun. Eşleştir.\", \"Tekrarla!\"], \"badges\": [[\"100+ bölüm\", \"her güncellemede yenileri\"], [\"İnternetsiz\", \"hesap gerekmez\"]] },\n \"play\": { \"eyebrow\": \"Oyna\", \"title\": [\"Öğrenmesi kolay,\", \"bırakması zor\"], \"sub\": \"Tek parmak, kısa turlar, büyük keyifli anlar.\", \"badges\": [[\"Kısa turlar\", \"Her biri iki dakika\"], [\"Harika!\"]] },\n \"levels\": { \"eyebrow\": \"Dünyalar\", \"title\": [\"Yeni dünyaları\", \"keşfet\"], \"sub\": \"Her on bölümde yeni bir görünüm ve yeni kurallar.\", \"badges\": [[\"10 dünya\", \"Her 10 bölümde yeni biri\"]] },\n \"stars\": { \"eyebrow\": \"Yıldızlar\", \"title\": [\"Akıllı oyna,\", \"3 yıldızı kap\"], \"sub\": \"Hamle artırarak bitir, tüm yıldızları topla.\", \"badges\": [[\"3 yıldız\"]] }\n },\n \"es\": {\n \"hero\": { \"title\": [\"Toca. Combina.\", \"¡Repite!\"], \"badges\": [[\"100+ niveles\", \"y más en cada versión\"], [\"Sin conexión\", \"Sin cuenta\"]] },\n \"play\": { \"eyebrow\": \"Juega\", \"title\": [\"Fácil de aprender,\", \"difícil de soltar\"], \"sub\": \"Un pulgar, rondas cortas, grandes momentos.\", \"badges\": [[\"Rondas cortas\", \"Dos minutos cada una\"], [\"¡Genial!\"]] },\n \"levels\": { \"eyebrow\": \"Mundos\", \"title\": [\"Explora\", \"nuevos mundos\"], \"sub\": \"Un aspecto nuevo y nuevas reglas cada diez niveles.\", \"badges\": [[\"10 mundos\", \"Uno nuevo cada 10 niveles\"]] },\n \"stars\": { \"eyebrow\": \"Estrellas\", \"title\": [\"Juega con cabeza,\", \"logra 3 estrellas\"], \"sub\": \"Termina con movimientos de sobra y consigue todas las estrellas.\", \"badges\": [[\"3 estrellas\"]] }\n },\n \"ja\": {\n \"hero\": { \"title\": [\"タップして、そろえて、\", \"もう一回!\"], \"badges\": [[\"100以上のステージ\", \"アップデートで追加\"], [\"オフラインで遊べる\", \"アカウント不要\"]] },\n \"play\": { \"eyebrow\": \"プレイ\", \"title\": [\"かんたんなのに\", \"やめられない\"], \"sub\": \"片手で遊べる短いラウンド、気持ちいい瞬間がたくさん。\", \"badges\": [[\"短いラウンド\", \"1回2分\"], [\"すごい!\"]] },\n \"levels\": { \"eyebrow\": \"ワールド\", \"title\": [\"新しいワールドを\", \"冒険しよう\"], \"sub\": \"10ステージごとに新しい見た目と新しいルール。\", \"badges\": [[\"10のワールド\", \"10ステージごとに追加\"]] },\n \"stars\": { \"eyebrow\": \"スター\", \"title\": [\"かしこく遊んで\", \"星を3つゲット\"], \"sub\": \"手数を残してクリアし、すべての星を集めよう。\", \"badges\": [[\"星3つ\"]] }\n },\n \"ar\": {\n \"hero\": { \"title\": [\"انقر. طابق.\", \"كرّر!\"], \"badges\": [[\"أكثر من 100 مرحلة\", \"والمزيد مع كل تحديث\"], [\"العب دون اتصال\", \"دون حساب\"]] },\n \"play\": { \"eyebrow\": \"العب\", \"title\": [\"سهلة التعلّم،\", \"صعبة التوقف\"], \"sub\": \"إبهام واحد، جولات قصيرة، ولحظات ممتعة.\", \"badges\": [[\"جولات قصيرة\", \"دقيقتان لكل جولة\"], [\"رائع!\"]] },\n \"levels\": { \"eyebrow\": \"العوالم\", \"title\": [\"استكشف\", \"عوالم جديدة\"], \"sub\": \"شكل جديد وقواعد جديدة كل عشر مراحل.\", \"badges\": [[\"10 عوالم\", \"عالم جديد كل 10 مراحل\"]] },\n \"stars\": { \"eyebrow\": \"النجوم\", \"title\": [\"العب بذكاء،\", \"واجمع 3 نجوم\"], \"sub\": \"أنهِ المرحلة بحركات متبقية واجمع كل النجوم.\", \"badges\": [[\"3 نجوم\"]] }\n }\n}\n", - "storebound.config.ts": "// storebound config for one game. Everything game-specific lives here; credentials NEVER do (they go in env vars or\n// .storebound/credentials.json, which must stay gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d ).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'],\n\n app: {\n name: 'Example Game',\n profile: 'game',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n supportUrl: 'https://example.com',\n marketingUrl: 'https://example.com',\n // Developer website on Play: the same domain must serve /app-ads.txt if the game shows ads\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n { id: 'es', play: ['es-ES', 'es-419'], apple: 'es-ES' },\n { id: 'ja', play: 'ja-JP', apple: 'ja' },\n { id: 'ar', play: 'ar', apple: 'ar-SA', rtl: true },\n ],\n\n play: {\n packageName: 'com.example.game',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n // read by `storebound scan` (fill in what is true for your account and app):\n // accountType: 'personal', accountCreated: 'YYYY-MM-DD', // new personal accounts: 12 testers x 14 days closed test\n // targetAges: ['13-15', '16-17', '18+'], // App content > Target audience; under 13 = Families policy\n },\n\n apple: {\n bundleId: 'com.example.game',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n // appId: '1234567890', // Apple ID of the app record; looked up by bundle id when left out\n listing: 'store/appstore.md',\n reviewShots: 'store/iap-review',\n // Export compliance (export-compliance skill). false ONLY if the game uses no encryption beyond the OS (HTTPS through\n // the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption.\n // usesNonExemptEncryption: false,\n // ipad: true, // the app runs on iPad: 13\" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded\n // iphoneMedium: true, // optional 6.3\" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5\" set down\n // iphoneDuo: true, // optional iPhone Duo (foldable) sets, outer 1398 x 2034 + inner 2007 x 2853; rendered and checked, not uploaded until the API takes them\n // screenshotTypes: ['APP_IPHONE_61'], // what `storebound validate` requires per locale (default: medium display, met by a 6.9\" or 6.5\" set; + APP_IPAD_PRO_3GEN_129 with ipad)\n // TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.\n testflight: {\n groups: [\n { name: 'Team', internal: true, allBuilds: true },\n { name: 'Friends' },\n ],\n description: { en: 'Example Game beta: try the new levels and tell us what feels too hard.' },\n },\n info: {\n categories: { primary: 'GAMES', primarySubOne: 'GAMES_CASUAL', primarySubTwo: 'GAMES_PUZZLE', secondary: 'ENTERTAINMENT' },\n ageRating: { advertising: true }, // everything else NONE / false\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Single-player game. No account or login is needed; there is no user-generated content or chat.',\n 'In-app purchases: Home > Shop. Restore Purchases is in Settings.',\n 'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.',\n 'Privacy policy: Settings > Privacy.',\n // With remote config, feature flags or a remotely changed paywall (storebound scan warns, 2.3.1) you may add:\n // 'Shop offers and prices shown come from and change remotely; no feature is switched on or off after review.',\n // Google Play has no review-notes field: put the same line in reviewAccess.instructions (Play App content >\n // App access, 'Any other information') when the app has a sign-in, and say in the full description what needs\n // a paid subscription (Play: Deceptive behavior, Payments).\n ].join('\\n\\n'),\n },\n },\n\n // Price changes later for many products / regions: storebound prices export, edit store/prices.csv, prices import --dry.\n // prices: { csv: 'store/prices.csv', baseTerritory: 'USA' },\n\n // Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a\n // region by hand (Apple needs an existing price point with exactly that value).\n products: [\n {\n id: 'com.example.game.remove_ads', type: 'non_consumable', usd: 2.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Remove Ads', description: 'Removes interstitial ads for good.' },\n tr: { title: 'Reklamları Kaldır', description: 'Ara reklamları kalıcı olarak kaldırır.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n {\n id: 'com.example.game.coins_small', type: 'consumable', usd: 0.99,\n listings: {\n en: { title: '500 Coins', description: 'A small bag of coins.' },\n tr: { title: '500 Altın', description: 'Küçük bir kese altın.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Purposes: functionality, analytics, communications,\n // fraud, advertising, personalization, account. Typical F2P game with an analytics SDK + ad mediation:\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no\n // email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's\n // \"Username and other authentication\"; `storebound scan` proposes it). With accounts Play also requires the\n // delete-account URL (from accounts.deletionUrl). That page also answers \"can users request that their data is\n // deleted?\" with yes (dataDeletion: 'yes' | 'no' | 'auto90' only for apps without one). partialDeletion: true +\n // partialDeletionUrl when a page deletes data without deleting the account. `storebound check` compares every row with Play's\n // current form (cli/data/play-data-safety-form.csv).\n accountCreation: ['PSL_ACM_NONE'],\n data: {\n PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },\n PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] },\n },\n },\n\n // Ads: `storebound validate --play` checks /app-ads.txt for these lines (copy them from your mediation\n // dashboard's app-ads.txt page). Placeholder line below; replace it.\n // ads: { appAdsTxt: ['example-network.com, pub-0000000000000000, DIRECT, 0000000000000000'] },\n\n renders: 'store/renders',\n\n shots: {\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n logo: 'store/shots/logo.png', // optional: hero scene + feature graphic\n // Google Fonts; both cover Latin Extended (tr, pl, cs, ro ...) and Vietnamese. `shots fonts` checks every glyph of the\n // copy; a character the font lacks needs a font that has it or fallback: ['Noto Sans Display'] (else shots check fails).\n fonts: { title: 'Paytone One', body: 'Montserrat' },\n // phone: { w: 440, h: 902, dpr: 3 }, // raw frame size in CSS px (screen below a 54 px status bar)\n // tablet: { w: 1032, h: 1352, dpr: 2 }, // default with apple.ipad (13\" iPad below its 24 px bar), else 800 x 1240\n scenes: [\n { id: 'hero', palette: 'fire', hero: true, over: 34, frames: [{ src: 'hero.png', rotate: -4 }],\n badges: [{ y: 0.42, side: 'l', icon: 'flag', iconBg: 'red', rot: -4 }, { y: 0.66, side: 'r', icon: 'offline', iconBg: 'red', rot: 3 }] },\n { id: 'play', palette: 'violet', frames: [{ src: 'gameplay.png', rotate: 3 }],\n badges: [{ y: 0.3, side: 'l', icon: 'bolt', iconBg: 'gold', rot: -3 }, { y: 0.7, side: 'r', hot: true, rot: 4 }] },\n { id: 'levels', palette: 'sky', frames: [{ src: 'level-b.png', scale: 0.82 }, { src: 'level-a.png', x: -31, dy: 10, scale: 0.7, rotate: -8 }, { src: 'level-c.png', x: 31, dy: 10, scale: 0.7, rotate: 8 }],\n badges: [{ y: 0.66, side: 'r', icon: 'map', iconBg: 'blue', rot: 3 }] },\n { id: 'stars', palette: 'gold', frames: [{ src: 'results.png', rotate: -3 }],\n badges: [{ y: 0.42, side: 'l', stars: true, rot: -3 }] },\n ],\n feature: { frame: 'hero.png', palette: 'fire' },\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&noads=1&seed=1',\n shots: {\n 'hero': { path: '/?level=12', waitFor: \"window.game && game.state === 'playing'\", delayMs: 1500, device: 'both' },\n 'gameplay': { path: '/?level=3', delayMs: 2500, device: 'both' },\n 'level-a': { path: '/?level=20', delayMs: 2000 },\n 'level-b': { path: '/?level=40', delayMs: 2000 },\n 'level-c': { path: '/?level=60', delayMs: 2000 },\n 'results': { path: '/?level=5&result=3', delayMs: 2000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png', // 1024 opaque\n foreground: 'store/icon/foreground.png', // 1024 RGBA, subject on transparent\n background: 'store/icon/background.png', // 1024 opaque\n out: 'store/icons',\n web: 'public/icons',\n // capacitorAssets: 'native/assets',\n // capacitorCommand: 'bunx @capacitor/assets generate --assetPath native/assets',\n // androidRes: 'native/android/app/src/main/res',\n },\n};\n\nexport default config;\n", + "storebound.config.ts": "// storebound config for one game. Everything game-specific lives here; credentials NEVER do (they go in env vars or\n// .storebound/credentials.json, which must stay gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d ).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'],\n\n app: {\n name: 'Example Game',\n profile: 'game',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n supportUrl: 'https://example.com',\n marketingUrl: 'https://example.com',\n // Developer website on Play: the same domain must serve /app-ads.txt if the game shows ads\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n { id: 'es', play: ['es-ES', 'es-419'], apple: 'es-ES' },\n { id: 'ja', play: 'ja-JP', apple: 'ja' },\n { id: 'ar', play: 'ar', apple: 'ar-SA', rtl: true },\n ],\n\n play: {\n packageName: 'com.example.game',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n // read by `storebound scan` (fill in what is true for your account and app):\n // accountType: 'personal', accountCreated: 'YYYY-MM-DD', // new personal accounts: 12 testers x 14 days closed test\n // targetAges: ['13-15', '16-17', '18+'], // App content > Target audience; under 13 = Families policy\n },\n\n apple: {\n bundleId: 'com.example.game',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n // appId: '1234567890', // Apple ID of the app record; looked up by bundle id when left out\n listing: 'store/appstore.md',\n reviewShots: 'store/iap-review',\n // Export compliance (export-compliance skill). false ONLY if the game uses no encryption beyond the OS (HTTPS through\n // the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption.\n // usesNonExemptEncryption: false,\n // ipad: true, // the app runs on iPad: 13\" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded\n // iphoneMedium: true, // optional 6.3\" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5\" set down\n // releaseType: \"afterApproval\", // afterApproval | manual | scheduled (+ earliestReleaseDate): how an approved version goes live; unset = new versions MANUAL\n // iphoneDuo: true, // optional iPhone Duo (foldable) sets, outer 1398 x 2034 + inner 2007 x 2853; rendered and checked, not uploaded until the API takes them\n // screenshotTypes: ['APP_IPHONE_61'], // what `storebound validate` requires per locale (default: medium display, met by a 6.9\" or 6.5\" set; + APP_IPAD_PRO_3GEN_129 with ipad)\n // TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.\n testflight: {\n groups: [\n { name: 'Team', internal: true, allBuilds: true },\n { name: 'Friends' },\n ],\n description: { en: 'Example Game beta: try the new levels and tell us what feels too hard.' },\n },\n info: {\n categories: { primary: 'GAMES', primarySubOne: 'GAMES_CASUAL', primarySubTwo: 'GAMES_PUZZLE', secondary: 'ENTERTAINMENT' },\n ageRating: { advertising: true }, // everything else NONE / false\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Single-player game. No account or login is needed; there is no user-generated content or chat.',\n 'In-app purchases: Home > Shop. Restore Purchases is in Settings.',\n 'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.',\n 'Privacy policy: Settings > Privacy.',\n // With remote config, feature flags or a remotely changed paywall (storebound scan warns, 2.3.1) you may add:\n // 'Shop offers and prices shown come from and change remotely; no feature is switched on or off after review.',\n // Google Play has no review-notes field: put the same line in reviewAccess.instructions (Play App content >\n // App access, 'Any other information') when the app has a sign-in, and say in the full description what needs\n // a paid subscription (Play: Deceptive behavior, Payments).\n ].join('\\n\\n'),\n },\n },\n\n // Price changes later for many products / regions: storebound prices export, edit store/prices.csv, prices import --dry.\n // prices: { csv: 'store/prices.csv', baseTerritory: 'USA' },\n\n // Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a\n // region by hand (Apple needs an existing price point with exactly that value).\n products: [\n {\n id: 'com.example.game.remove_ads', type: 'non_consumable', usd: 2.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Remove Ads', description: 'Removes interstitial ads for good.' },\n tr: { title: 'Reklamları Kaldır', description: 'Ara reklamları kalıcı olarak kaldırır.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n {\n id: 'com.example.game.coins_small', type: 'consumable', usd: 0.99,\n listings: {\n en: { title: '500 Coins', description: 'A small bag of coins.' },\n tr: { title: '500 Altın', description: 'Küçük bir kese altın.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Purposes: functionality, analytics, communications,\n // fraud, advertising, personalization, account. Typical F2P game with an analytics SDK + ad mediation:\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no\n // email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's\n // \"Username and other authentication\"; `storebound scan` proposes it). With accounts Play also requires the\n // delete-account URL (from accounts.deletionUrl). That page also answers \"can users request that their data is\n // deleted?\" with yes (dataDeletion: 'yes' | 'no' | 'auto90' only for apps without one). partialDeletion: true +\n // partialDeletionUrl when a page deletes data without deleting the account. `storebound check` compares every row with Play's\n // current form (cli/data/play-data-safety-form.csv).\n accountCreation: ['PSL_ACM_NONE'],\n data: {\n PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },\n PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] },\n },\n },\n\n // Ads: `storebound validate --play` checks /app-ads.txt for these lines (copy them from your mediation\n // dashboard's app-ads.txt page). Placeholder line below; replace it.\n // ads: { appAdsTxt: ['example-network.com, pub-0000000000000000, DIRECT, 0000000000000000'] },\n\n renders: 'store/renders',\n\n shots: {\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n logo: 'store/shots/logo.png', // optional: hero scene + feature graphic\n // Google Fonts; both cover Latin Extended (tr, pl, cs, ro ...) and Vietnamese. `shots fonts` checks every glyph of the\n // copy; a character the font lacks needs a font that has it or fallback: ['Noto Sans Display'] (else shots check fails).\n fonts: { title: 'Paytone One', body: 'Montserrat' },\n // phone: { w: 440, h: 902, dpr: 3 }, // raw frame size in CSS px (screen below a 54 px status bar)\n // tablet: { w: 1032, h: 1352, dpr: 2 }, // default with apple.ipad (13\" iPad below its 24 px bar), else 800 x 1240\n scenes: [\n { id: 'hero', palette: 'fire', hero: true, over: 34, frames: [{ src: 'hero.png', rotate: -4 }],\n badges: [{ y: 0.42, side: 'l', icon: 'flag', iconBg: 'red', rot: -4 }, { y: 0.66, side: 'r', icon: 'offline', iconBg: 'red', rot: 3 }] },\n { id: 'play', palette: 'violet', frames: [{ src: 'gameplay.png', rotate: 3 }],\n badges: [{ y: 0.3, side: 'l', icon: 'bolt', iconBg: 'gold', rot: -3 }, { y: 0.7, side: 'r', hot: true, rot: 4 }] },\n { id: 'levels', palette: 'sky', frames: [{ src: 'level-b.png', scale: 0.82 }, { src: 'level-a.png', x: -31, dy: 10, scale: 0.7, rotate: -8 }, { src: 'level-c.png', x: 31, dy: 10, scale: 0.7, rotate: 8 }],\n badges: [{ y: 0.66, side: 'r', icon: 'map', iconBg: 'blue', rot: 3 }] },\n { id: 'stars', palette: 'gold', frames: [{ src: 'results.png', rotate: -3 }],\n badges: [{ y: 0.42, side: 'l', stars: true, rot: -3 }] },\n ],\n feature: { frame: 'hero.png', palette: 'fire' },\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&noads=1&seed=1',\n shots: {\n 'hero': { path: '/?level=12', waitFor: \"window.game && game.state === 'playing'\", delayMs: 1500, device: 'both' },\n 'gameplay': { path: '/?level=3', delayMs: 2500, device: 'both' },\n 'level-a': { path: '/?level=20', delayMs: 2000 },\n 'level-b': { path: '/?level=40', delayMs: 2000 },\n 'level-c': { path: '/?level=60', delayMs: 2000 },\n 'results': { path: '/?level=5&result=3', delayMs: 2000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png', // 1024 opaque\n foreground: 'store/icon/foreground.png', // 1024 RGBA, subject on transparent\n background: 'store/icon/background.png', // 1024 opaque\n out: 'store/icons',\n web: 'public/icons',\n // capacitorAssets: 'native/assets',\n // capacitorCommand: 'bunx @capacitor/assets generate --assetPath native/assets',\n // androidRes: 'native/android/app/src/main/res',\n },\n};\n\nexport default config;\n", "testers.csv": "email,first_name,last_name,groups\n", "web/appstore.svg": "App Store", "web/googleplay.svg": "Google Play", "web/landing.html": "\n\n\n\n\n{{APP_NAME}}\n\n\n\n\n\n
\n

{{APP_NAME}}

\n \n
\n\n\n\n", - "workflows/beta.yml": "# storebound flow \"beta\" for a game: a playtest build on TestFlight and Play internal testing.\n# storebound flow run beta VERSION=1.2.0 [--apply]\ndescription: Next game build to TestFlight and Play internal testing\nparams:\n VERSION: \"1.0.0\"\n GROUP: \"Playtesters\"\nsteps:\n - id: check\n run: check\n - id: ads\n if: exists ios\n run: ads scan --ios ios\n continueOnError: true\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: distribute\n if: apple\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: external\n owner: \"External playtesters: storebound apple testflight distribute --build ${BUILD} --group --submit-review (Beta App Review is your call)\"\n", - "workflows/release.yml": "# storebound flow \"release\" for a game: one version on both stores, up to your Submit click.\n# storebound flow run release VERSION=1.2.0 reads run, writes run as dry runs (nothing changes)\n# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first\n# storebound flow run release --resume --apply continue after fixing a failed step\n# Steps are storebound commands (no \"storebound\" prefix). `if: apple` / `if: play` skip a store the config does not\n# have; `owner:` steps are never run, they list what you click. `sh:` runs your own build command (only with --apply).\ndescription: Build, upload and check one game version on both stores, then your Submit click\nparams:\n VERSION: \"1.0.0\"\nsteps:\n - id: shots\n if: exists store/renders\n run: shots check\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: whats-new\n if: exists store/whats-new\n run: whats-new apply\n - id: listing\n if: apple\n run: apple listing --version ${VERSION} --build ${BUILD}\n - id: validate\n run: validate\n - id: scan\n run: scan\n - id: plan\n run: release plan ${VERSION}\n - id: submit\n owner: \"App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the internal release and Send changes for review\"\n", + "workflows/beta.yml": "# storebound flow \"beta\" for a game: a playtest build on TestFlight and Play internal testing.\n# storebound flow run beta VERSION=1.2.0 [--apply]\n# VERSION is required (\"?\"). GROUP empty = the internal group of apple.testflight.groups (the group `internal: true`);\n# GROUP= picks a TestFlight group that exists (or is listed in apple.testflight.groups).\ndescription: Next game build to TestFlight and Play internal testing\nparams:\n VERSION: \"?\"\n GROUP: \"\"\nsteps:\n - id: check\n run: check\n - id: ads\n if: exists ios\n run: ads scan --ios ios\n continueOnError: true\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: distribute\n if: apple && ${GROUP}\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: distribute-internal\n if: apple && !${GROUP} && ${config.testflight.internal}\n run: apple testflight distribute --build ${BUILD} --group ${config.testflight.internal}\n - id: no-group\n if: apple && !${GROUP} && !${config.testflight.internal}\n owner: \"No TestFlight group: pass GROUP= or add a group with internal: true to apple.testflight.groups, then storebound apple testflight distribute --build ${BUILD} --group \"\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: external\n owner: \"External playtesters: storebound apple testflight distribute --build ${BUILD} --group --submit-review (Beta App Review is your call)\"\n", + "workflows/release.yml": "# storebound flow \"release\" for a game: one version on both stores, up to your Submit click.\n# storebound flow run release VERSION=1.2.0 reads run, writes run as dry runs (nothing changes)\n# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first\n# storebound flow run release --resume --apply continue after fixing a failed step\n# Steps are storebound commands (no \"storebound\" prefix). `if: apple` / `if: play` skip a store the config does not\n# have; `if: ${PARAM}` turns an optional step on by a param (MAC_TAURI= adds the macOS build, PLAY_TRACK=beta\n# uploads to Play open testing); VERSION is required; `owner:` steps are never run, they list what you click. `sh:` runs your own build command (only with --apply).\ndescription: Build, upload and check one game version on both stores, then your Submit click\nparams:\n VERSION: \"?\"\n MAC_TAURI: \"\"\n PLAY_TRACK: \"internal\"\nsteps:\n - id: shots\n if: exists store/renders\n run: shots check\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: mac-build\n if: apple && ${MAC_TAURI}\n run: apple build --platform macos --tauri ${MAC_TAURI} --version ${VERSION} --build-number ${BUILD}\n - id: mac-upload\n if: apple && ${MAC_TAURI}\n run: apple upload --platform macos --no-wait --allow-existing\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track ${PLAY_TRACK}\n - id: promote\n if: play && ${PLAY_TRACK} != production\n owner: \"Play Console: promote the ${PLAY_TRACK} release to production (storebound play release --version-code --production completed once you agree)\"\n - id: whats-new\n if: exists store/whats-new\n run: whats-new apply\n - id: listing\n if: apple\n run: apple listing --version ${VERSION} --build ${BUILD}\n - id: validate\n run: validate\n - id: scan\n run: scan\n - id: plan\n run: release plan ${VERSION}\n - id: submit\n owner: \"App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the ${PLAY_TRACK} release and Send changes for review\"\n", }; /** The pages shots render / video load (cli/shots/*.html), by file name. */ diff --git a/cli/lib/fastlane.ts b/cli/lib/fastlane.ts index 36a04ab..1cc3ec8 100644 --- a/cli/lib/fastlane.ts +++ b/cli/lib/fastlane.ts @@ -617,7 +617,7 @@ ${secs.join('\n\n')} const ACT: Record = { app_store_connect_api_key: 'ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json (the .p8 path, never its contents)', upload_to_app_store: '`storebound apple listing`, `apple screenshots`, `apple previews` (dry run first; submitting stays with the owner)', deliver: '`storebound apple listing` / `apple screenshots`', - upload_to_play_store: '`storebound play listing`, `play images`, `play release ` (testing tracks; production is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`', + upload_to_play_store: '`storebound play listing`, `play images`, `play release ` (testing tracks; production updates of a published app: --production completed --yes; the first-ever release is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`', upload_to_testflight: '`storebound apple testflight` (testflight skill)', pilot: '`storebound apple testflight` (testflight skill)', match: 'keep match for signing', sync_code_signing: 'keep match for signing', snapshot: '`storebound shots capture --source ios-sim`', capture_screenshots: '`storebound shots capture --source ios-sim`', capture_ios_screenshots: '`storebound shots capture --source ios-sim`', diff --git a/cli/lib/flow.ts b/cli/lib/flow.ts index 238ade6..1993cbd 100644 --- a/cli/lib/flow.ts +++ b/cli/lib/flow.ts @@ -205,7 +205,7 @@ export function validateFlows(flows: Flow[], o: { commands?: Set } = {}) if (ids.has(s.id)) e.push(`${at}: id used twice`); ids.add(s.id); for (const t of [s.run, s.sh, s.owner, s.if].filter((x): x is string => typeof x === 'string')) - for (const m of t.matchAll(/\$\{([^}]*)\}/g)) if (!vars.has(m[1]!) && !/^env\.[A-Z0-9_]+$/.test(m[1]!)) e.push(`${at}: \${${m[1]}} is not a param or an earlier capture`); + for (const m of t.matchAll(/\$\{([^}]*)\}/g)) if (!vars.has(m[1]!) && !/^env\.[A-Z0-9_]+$/.test(m[1]!) && !CONFIG_VARS.includes(m[1]!)) e.push(`${at}: \${${m[1]}} is not a param or an earlier capture`); if (s.capture) vars.add(s.capture); if (s.run !== undefined) { let argv: string[] = []; @@ -230,6 +230,14 @@ export function validateFlows(flows: Flow[], o: { commands?: Set } = {}) // ---- variables, conditions ---- +/** Variables the runner fills from the config (empty when the config has none), usable in `run` and `if`. */ +export const CONFIG_VARS = ['config.testflight.internal']; + +/** The required-param marker: `VERSION: "?"` in params means the run needs VERSION=... (no silent default). */ +export const REQUIRED = '?'; +/** Params still at the required marker (not given on the command line). Pure. */ +export function missingParams(vars: Record): string[] { return Object.keys(vars).filter(k => vars[k] === REQUIRED); } + /** ${VAR} and ${env.NAME} substituted; unknown ones stay as written (the plan shows them). */ export function substitute(text: string, vars: Record, env: Record = {}): string { return text.replace(/\$\{([^}]*)\}/g, (m, k: string) => (k.startsWith('env.') ? env[k.slice(4)] ?? m : vars[k] ?? m)); @@ -306,6 +314,39 @@ export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, var return { ...base, kind, argv: addDry ? [...argv, '--dry'] : addYes ? [...argv, '--yes'] : argv, dryAdded: addDry, ...(addYes ? { yesAdded: true } : {}), ...(mode === 'plan' ? { skip: 'plan only' } : {}) }; } +const PRODUCERS = ['apple build', 'play build']; +const CONSUMERS = ['apple upload', 'play upload']; +const AFTER_UPLOAD = ['apple testflight wait', 'apple testflight distribute']; + +/** Stores whose artifact (IPA / AAB / PKG) or uploaded build only exists after a real earlier step ("apple", "apple:mac", "play"). */ +export interface DryPending { built: Set; uploaded: Set } + +const storeKey = (argv: string[]): string => { + const i = argv.indexOf('--platform'); + const mac = (i >= 0 && /^(macos|mac)$/i.test(argv[i + 1] ?? '')) || argv.includes('--tauri'); + return `${argv[0]}${mac ? ':mac' : ''}`; +}; + +/** Records a step that ran as a dry run: a dry build leaves no IPA / AAB. Pure. */ +export function noteDry(p: PlannedStep, pending: DryPending): void { + if (!p.argv || !p.dryAdded || p.skip) return; + if (PRODUCERS.includes(words(p.argv).slice(0, 2).join(' '))) pending.built.add(storeKey(p.argv)); +} + +/** Why a step is only planned in a dry run: its input (the IPA / AAB, or the uploaded build) would come from an earlier + * step that did not really run. null = run it (always null with --apply: a real upload with no IPA still fails). Pure. */ +export function plannedForDryInput(p: PlannedStep, mode: Mode, pending: DryPending): string | null { + if (mode === 'apply' || !p.argv || p.skip) return null; + const w = words(p.argv); + const k2 = w.slice(0, 2).join(' '); + if (CONSUMERS.includes(k2) && !p.argv.some(a => /\.(ipa|aab|pkg)$/.test(a)) && pending.built.has(storeKey(p.argv))) { + pending.uploaded.add(storeKey(p.argv)); + return `planned: would upload the ${k2 === 'play upload' ? 'AAB' : storeKey(p.argv).endsWith(':mac') ? 'PKG' : 'IPA'} the ${w[0]} build step writes (dry run: nothing built)`; + } + if (AFTER_UPLOAD.includes(w.slice(0, 3).join(' ')) && pending.uploaded.has('apple')) return 'planned: the build is not on App Store Connect in a dry run'; + return null; +} + /** Index of the step to start from: --from (exact, or the first id under a sub-flow prefix). */ export function startIndex(steps: FlowStep[], from: string | undefined): number { if (!from) return 0; diff --git a/cli/lib/google-access.ts b/cli/lib/google-access.ts new file mode 100644 index 0000000..b73f275 --- /dev/null +++ b/cli/lib/google-access.ts @@ -0,0 +1,34 @@ +// A Google 401 / 403 that says what is missing (SERVICE_DISABLED, PERMISSION_DENIED, a refused key) becomes one +// GoogleAccessError with the exact API or grant and the link to fix it. It is never retried: the same call fails the +// same way until the owner (or the credential broker) changes something. With the broker as the token source the text +// also names the Play permissions the broker lists for the package and the broker-side fix. +import { brokerConfig, brokerPlayInfo, grantsOf, redact } from './broker'; +import { findingText, type GoogleFinding, parseGoogleError } from './google-errors'; + +export class GoogleAccessError extends Error { + /** Never sent again: retry loops and the upsert layer check this. */ + readonly noRetry = true; + constructor(msg: string, readonly finding: GoogleFinding) { super(msg); } +} + +/** The GoogleAccessError for a failed call's text, or null when the text is another error. */ +export async function accessError(text: string, o: { app?: string | null; root?: string; source?: 'broker' | 'file' } = {}): Promise { + const f = parseGoogleError(text, { app: o.app ?? null }); + if (!f || f.kind === 'quota') return null; + const lines = [redact(text), findingText(f, { withUrl: true })]; + if (o.source === 'broker') { + if (f.kind === 'auth-revoked') lines.push('The token came from the credential broker: its key for this service account was revoked or deleted at Google; rotate the key on the broker.'); + if (f.kind === 'api-disabled') lines.push(`Or enable it on the credential broker (google_api_enable ${f.projectId ?? ''} ${f.service}).`); + if (f.kind === 'no-app-access' && o.root && o.app) { + try { + const cfg = brokerConfig(o.root); + const r = cfg ? await brokerPlayInfo(cfg, o.app) : null; + if (r?.kind === 'ok') { + const g = grantsOf(r.info); + lines.push(`The credential broker lists ${g ? (g.length ? g.join(', ') : 'no Play permissions') : 'no permission data'} for ${r.info.account ?? 'the service account'} on ${o.app}: grant the missing one there (google_play_grant).`); + } else if (r) lines.push(r.message); + } catch { /* the Google error stands on its own */ } + } + } + return new GoogleAccessError(lines.join('\n'), f); +} diff --git a/cli/lib/google-auth.ts b/cli/lib/google-auth.ts index 106f798..aaec4f4 100644 --- a/cli/lib/google-auth.ts +++ b/cli/lib/google-auth.ts @@ -1,5 +1,7 @@ // OAuth 2.0 service account flow (RS256 JWT assertion -> access token) with WebCrypto, no googleapis dependency. -import type { ServiceAccount } from './credentials'; +import { playServiceAccount, type ServiceAccount } from './credentials'; +import { BROKER_SCOPES, type BrokerConfig, brokerConfig, brokerKey, brokerPlayInfo, type BrokerScope, brokerToken, BrokerUnavailable, BrokerUnknownAccount, redact } from './broker'; +import { warn } from './log'; export const PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'; /** Play Developer Reporting API (vitals): https://developers.google.com/play/developer/reporting/reference/rest */ @@ -42,3 +44,97 @@ export function tokenSource(sa: ServiceAccount, scope = PLAY_SCOPE): () => Promi return token; }; } + +// ---- Play access through a credential broker (cli/lib/broker.ts) or a local key ---- + +/** The broker scope a Google scope maps to; null: the broker hands out no such scope (a local key is needed). */ +export const BROKER_SCOPE_OF: Record = { [PLAY_SCOPE]: 'androidpublisher', [STORAGE_READ_SCOPE]: 'cloud-platform.read-only' }; + +export type PlaySource = 'broker' | 'file'; + +/** A Play access token getter that also says where its tokens come from (no values). */ +export interface PlayToken { + (): Promise; + /** "broker" until the broker failed over to the local key (then "file"). */ + source(): PlaySource; + /** The service account e-mail the tokens are for, when known. */ + principal(): string | null; +} + +const fellBack = new Set(); + +/** + * The Play token getter of a project: the credential broker when it is configured and has the scope (POST + * /api/google/token), else the local service account key (env, profile, .storebound/credentials.json). The broker not + * answering, or not knowing the account, falls back to the local key once with a warning; a broker that refuses the + * API key (401 / 403) fails. `brokerScope: null` forces the local key (writes the broker has no scope for). + * Throws at once when neither is there. + */ +export function playToken(root: string, scope = PLAY_SCOPE, o: { packageName?: string; brokerScope?: BrokerScope | null; warn?: (msg: string) => void } = {}): PlayToken { + let sa: ServiceAccount | null = null, saErr: Error | null = null; + try { sa = playServiceAccount(root); } catch (e) { saErr = e as Error; } + const cfg = brokerConfig(root); + const bscope = o.brokerScope === undefined ? BROKER_SCOPE_OF[scope] ?? null : o.brokerScope; + const local = sa ? tokenSource(sa, scope) : null; + if (!cfg || !bscope) { + if (!local) { + if (cfg && !bscope) throw new Error(`${scope.replace('https://www.googleapis.com/auth/', '')}: the credential broker hands out no such scope (${BROKER_SCOPES.join(', ')}), and there is no local service account key (PLAY_SERVICE_ACCOUNT) for it`); + throw saErr!; + } + return Object.assign(() => local(), { source: (): PlaySource => 'file', principal: () => sa!.client_email }); + } + let account = cfg.account ?? sa?.client_email ?? null; + let mode: PlaySource = 'broker'; + const say = o.warn ?? ((m: string) => warn('credentials', m)); + const get = async (): Promise => { + if (mode === 'file') return local!(); + try { + if (!account && o.packageName) { + const r = await brokerPlayInfo(cfg, o.packageName); + if (r.kind === 'unavailable') throw new BrokerUnavailable(r.message); + if (r.kind === 'ok' && r.info.account) account = r.info.account; + } + if (!account) throw new Error('credential broker: which Google account? Set PLAY_SERVICE_ACCOUNT_EMAIL (env, the credential profile or .storebound/credentials.json) or "account" in broker.json'); + return await brokerToken(cfg, account, bscope); + } catch (e) { + if (local && (e instanceof BrokerUnavailable || e instanceof BrokerUnknownAccount)) { + const once = `${cfg.url}\n${sa!.client_email}`; + if (!fellBack.has(once)) { fellBack.add(once); say(`${redact((e as Error).message)}; using the local service account key instead`); } + mode = 'file'; + account = sa!.client_email; + return local(); + } + throw e; + } + }; + return Object.assign(get, { source: () => mode, principal: () => account }); +} + +export interface PlayCredentials { + /** Where Play tokens come from for this project: the broker, the local key file, or nowhere. */ + source: PlaySource | null; + /** The service account e-mail when known (masked by the callers that print it). */ + principal: string | null; + /** A local key is there too (the broker's fallback). */ + local: boolean; + /** The broker config (its URL and key file path, never the key), and what is wrong with it. */ + broker: { url: string; keyFile: string; from: string; error?: string } | null; + error?: string; +} + +/** Where this project's Play access comes from, without network calls and without values. */ +export function playCredentials(root: string): PlayCredentials { + let sa: ServiceAccount | null = null, error: string | undefined; + try { sa = playServiceAccount(root); } catch (e) { error = (e as Error).message; } + let cfg: BrokerConfig | null = null, brokerError: string | undefined; + try { cfg = brokerConfig(root); } catch (e) { brokerError = (e as Error).message; } + if (cfg) { try { brokerKey(cfg); } catch (e) { brokerError = (e as Error).message; } } + const broker = cfg ? { url: cfg.url, keyFile: cfg.keyFile, from: cfg.from, ...(brokerError ? { error: brokerError } : {}) } : null; + const usable = !!cfg && !brokerError; + return { + source: usable ? 'broker' : sa ? 'file' : null, + principal: (usable ? cfg!.account : null) ?? sa?.client_email ?? null, + local: !!sa, broker, + ...(brokerError || !(usable || sa) ? { error: brokerError ?? error } : {}), + }; +} diff --git a/cli/lib/gradle.ts b/cli/lib/gradle.ts index 9d868bc..be988b6 100644 --- a/cli/lib/gradle.ts +++ b/cli/lib/gradle.ts @@ -3,7 +3,7 @@ // properties through ORG_GRADLE_PROJECT_* variables, so passwords never appear on a command line), and read // package / versionCode / versionName back from the built AAB (its manifest is protocol buffers, aapt2 format). import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join, relative, resolve } from 'node:path'; import type { AndroidSigning } from './credentials'; import * as rt from './rt'; import { zipEntries, zipRead } from './zip'; @@ -38,7 +38,8 @@ export function pickGradle(root: string, cfg: PlayBuildConfig = {}): GradleTarge : ['capacitor.config.ts', 'capacitor.config.json', 'capacitor.config.js'].some(f => existsSync(join(root, f))) ? 'capacitor' : (() => { try { const p = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); return !!(p.dependencies?.['react-native'] ?? p.devDependencies?.['react-native']); } catch { return false; } })() ? 'react-native' : 'native'; - const dirs = cfg.dir ? [join(root, cfg.dir)] : [join(root, 'android'), root]; + // resolve, not join: an absolute play.build.dir / --dir stays absolute (join made an absolute path a folder under the root) + const dirs = cfg.dir ? [resolve(root, cfg.dir)] : [join(root, 'android'), root]; let dir = dirs.find(isGradleDir); if (!dir) throw new Error(`no Gradle project (settings.gradle) in ${dirs.map(d => relative(root, d) || '.').join(' or ')}: set play.build.dir${framework === 'capacitor' ? ' (or npx cap add android)' : ''}`); const unity = existsSync(join(dir, 'launcher')) && existsSync(join(dir, 'unityLibrary')); diff --git a/cli/lib/keychain.ts b/cli/lib/keychain.ts new file mode 100644 index 0000000..5106f43 --- /dev/null +++ b/cli/lib/keychain.ts @@ -0,0 +1,45 @@ +// Code signing keychain for `storebound apple build` outside the Mac's logged-in GUI session. codesign in an SSH +// session (or any session without the login keychain unlocked) fails late with errSecInternalComponent. The fixes: +// - run from the GUI session (a .command file opened with `open -a Terminal`) +// - a CI runner / Jenkins agent started in that session, or with an unlocked keychain +// - a build keychain: --keychain , unlocked with --keychain-unlock (password from STOREBOUND_KEYCHAIN_PASSWORD, +// never printed) and its key partition list set so codesign may use the keys without a prompt. +// Pure planning here; cli/apple/build.ts runs it. +export const KEYCHAIN_PASSWORD_ENV = 'STOREBOUND_KEYCHAIN_PASSWORD'; + +/** The refusal for a build in an SSH / non-GUI session, naming every fix. */ +export function sshBuildRefusal(reason: string, o: { unlockFlag?: boolean } = {}): string { + const unlock = o.unlockFlag !== false; + return `apple build: ${reason}; codesign there fails with errSecInternalComponent (locked keychain). Fixes, pick one:\n` + + ` 1. Run it in the Mac's logged-in GUI session: write the command into a .command file and \`open -a Terminal \` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside).\n` + + ` 2. A CI runner / Jenkins agent started in the GUI session, or one whose keychain is unlocked first (\`security unlock-keychain -p "$${KEYCHAIN_PASSWORD_ENV}" \`, then \`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$${KEYCHAIN_PASSWORD_ENV}" \`).\n` + + (unlock ? ` 3. Let storebound do step 2: export ${KEYCHAIN_PASSWORD_ENV}=... (the keychain password; never printed) and add --keychain-unlock (the login keychain), or --keychain --keychain-unlock for a build keychain that holds the Apple Distribution key + certificate (storebound apple signing ensure --keychain imports them).\n` + + ` --allow-ssh skips this check when the keychain is already unlocked in this session.` + : ' --allow-ssh skips this check when the keychain is already unlocked in this session.'); +} + +export interface KeychainPlan { keychain: string; steps: string[][] } + +/** The `security` calls --keychain-unlock runs, with PASSWORD as a placeholder (substituted by the caller, never logged). */ +export function unlockSteps(keychain: string, searchList: string[]): string[][] { + const steps: string[][] = [['security', 'unlock-keychain', '-p', 'PASSWORD', keychain], + ['security', 'set-key-partition-list', '-S', 'apple-tool:,apple:,codesign:', '-s', '-k', 'PASSWORD', keychain]]; + if (!searchList.includes(keychain)) steps.push(['security', 'list-keychains', '-d', 'user', '-s', keychain, ...searchList]); + return steps; +} + +/** `security list-keychains` output -> paths (quotes stripped). */ +export const parseKeychainList = (out: string): string[] => out.split('\n').map(l => l.trim().replace(/^"|"$/g, '')).filter(Boolean); + +/** The password from the environment, or an error that names the variable (the value is never in a message). */ +export function keychainPassword(env: Record): string { + const p = env[KEYCHAIN_PASSWORD_ENV]; + if (!p) throw new Error(`--keychain-unlock needs the keychain password in the environment variable ${KEYCHAIN_PASSWORD_ENV} (not an argument: arguments end up in shell history)`); + return p; +} + +/** A command line for logs with the password replaced. */ +export const showSecurity = (argv: string[]): string => argv.map(a => (a === 'PASSWORD' ? '$' + KEYCHAIN_PASSWORD_ENV : a)).join(' '); + +/** Settings that make xcodebuild sign with a given keychain. */ +export const keychainSigningArgs = (keychain: string): string[] => [`OTHER_CODE_SIGN_FLAGS=--keychain ${keychain}`]; diff --git a/cli/lib/listing-mentions.ts b/cli/lib/listing-mentions.ts index f7f9fb5..b8aaac3 100644 --- a/cli/lib/listing-mentions.ts +++ b/cli/lib/listing-mentions.ts @@ -2,7 +2,7 @@ // report. Reads store/appstore.md and store/listing.md (or the config's listing paths). import { existsSync, readFileSync } from 'node:fs'; import { relative } from 'node:path'; -import type { Loaded } from './config'; +import { listingPathFor, type Loaded } from './config'; import { fileMentions, type PlatformHit } from './cross-store'; export function listingFileMentions(loaded: Pick, stores: { apple: boolean; play: boolean } = { apple: true, play: true }): PlatformHit[] { @@ -11,6 +11,7 @@ export function listingFileMentions(loaded: Pick; @@ -69,7 +71,7 @@ export const versionInfos = (rows: { id: string; attributes: Record }[], want: string, attrs: { copyright: string; releaseType: string }, platform = 'IOS'): VersionPlan { +export function planVersion(rows: { id: string; attributes: Record }[], want: string, attrs: { copyright: string; releaseType?: string; earliestReleaseDate?: string }, platform = 'IOS'): VersionPlan { const existing = versionInfos(rows); const editable = existing.find(v => EDITABLE_STATES.has(v.state)); const others = existing.filter(v => v !== editable); @@ -87,7 +89,8 @@ export function planVersion(rows: { id: string; attributes: Record r.id === editable.id)!.attributes; - const wantAttrs = { versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType }; + // releaseType only when asked for (apple.releaseType): an existing version keeps the one it has + const wantAttrs: Record = { versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType, earliestReleaseDate: attrs.releaseType === 'SCHEDULED' ? attrs.earliestReleaseDate : undefined }; const p = planItem(editable.id, { id: editable.id, attributes: cur }, wantAttrs); if (editable.versionString !== want && editable.state !== 'PREPARE_FOR_SUBMISSION') return refuse(`the editable version ${editable.versionString} is ${editable.state}; its version string changes only while PREPARE_FOR_SUBMISSION (use --version ${editable.versionString}, or change it in App Store Connect)`); @@ -102,8 +105,17 @@ export function planVersion(rows: { id: string; attributes: Record ({ field, from: null, to })), attributes }; + // without apple.releaseType a new version keeps the release type of the newest one before it (Acme 2026-10-29: an + // app the owner set to release after approval got a MANUAL 2.0.2 and would have sat unreleased); a first version is MANUAL + const prev = others.slice().sort((a, b) => compareVersions(b.versionString, a.versionString))[0]; + const prevType = prev ? rows.find(r => r.id === prev.id)?.attributes.releaseType : undefined; + const inherited = !attrs.releaseType && typeof prevType === 'string' && ['MANUAL', 'AFTER_APPROVAL'].includes(prevType) ? prevType : undefined; + const attributes: Record = { platform, versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType ?? inherited ?? 'MANUAL' }; + if (attributes.releaseType === 'SCHEDULED' && attrs.earliestReleaseDate) attributes.earliestReleaseDate = attrs.earliestReleaseDate; + const why = attrs.releaseType ? 'apple.releaseType' : inherited ? `as version ${prev!.versionString}` : prev ? `version ${prev.versionString} has ${String(prevType ?? 'no readable type')}` : 'first version'; + return { ...base, action: 'create', reason: `create version ${want} (no editable ${PLATFORM_LABEL[platform] ?? platform} version), release type ${attributes.releaseType} (${why})`, + changes: Object.entries(attributes).map(([field, to]) => ({ field, from: null, to })), attributes, + ...(attrs.releaseType && typeof prevType === 'string' && prevType !== attrs.releaseType ? { warning: `release type ${attrs.releaseType} (apple.releaseType) differs from version ${prev!.versionString} (${prevType})` } : {}) }; } // ---- translation drafts ---- diff --git a/cli/lib/mas.ts b/cli/lib/mas.ts index 0a9d650..b080e57 100644 --- a/cli/lib/mas.ts +++ b/cli/lib/mas.ts @@ -18,20 +18,52 @@ export interface TauriProject { category?: string; minOs?: string; entitlements?: string; + /** The overlay file merged over the base config (tauri..conf.json), absolute; passed to `tauri build --config` too. */ + overlayPath?: string; + /** True when the overlay was found next to tauri.conf.json without being named (tauri.appstore.conf.json / tauri.mas.conf.json). */ + overlayAuto?: boolean; conf: Record; } +/** Overlay files that are picked up on their own for a Mac App Store build, in this order. */ +export const MAS_OVERLAY_NAMES = ['tauri.appstore.conf.json', 'tauri.mas.conf.json'] as const; + +/** JSON merge patch (RFC 7386, what Tauri's --config does): objects merge, null removes a key, anything else (arrays too) replaces. */ +export function mergePatch(base: unknown, patch: unknown): unknown { + if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return patch; + const out: Record = base !== null && typeof base === 'object' && !Array.isArray(base) ? { ...(base as Record) } : {}; + for (const [k, v] of Object.entries(patch as Record)) { + if (v === null) delete out[k]; + else out[k] = mergePatch(out[k], v); + } + return out; +} + +/** tauri..conf.json (an overlay), not tauri.conf.json itself. */ +export const isOverlayFile = (p: string): boolean => /(^|[\\/])tauri\.[^\\/]+\.conf\.json$/.test(p); + const SKIP = new Set(['node_modules', 'target', '.git', 'dist', 'build', '.storebound', 'Pods']); -/** The Tauri project: `hint` (a folder with tauri.conf.json, or the file itself) or the only tauri.conf.json found. */ +/** The Tauri project: `hint` (a folder with tauri.conf.json, or a tauri.conf.json / tauri.conf.json5 / tauri..conf.json + * file) or the only tauri.conf.json found. An overlay file (tauri..conf.json) is merged over the base + * tauri.conf.json of its folder; tauri.appstore.conf.json / tauri.mas.conf.json next to the base config is picked up on its own. */ export function findTauri(root: string, hint?: string): TauriProject { const names = ['tauri.conf.json', 'tauri.conf.json5']; - let confPath: string | undefined; + let confPath: string | undefined, overlayPath: string | undefined, overlayAuto = false; + const autoOverlay = (): void => { const o = MAS_OVERLAY_NAMES.map(n => join(dirname(confPath!), n)).find(existsSync); if (o) { overlayPath = o; overlayAuto = true; } }; if (hint && hint !== 'true') { const p = join(root, hint); - if (/tauri\.conf\.json5?$/.test(p) && existsSync(p)) confPath = p; - else confPath = names.map(n => join(p, n)).find(existsSync); - if (!confPath) throw new Error(`--tauri ${hint}: no tauri.conf.json in it`); + if (isOverlayFile(p)) { + if (!existsSync(p)) throw new Error(`--tauri ${hint}: no such file`); + overlayPath = p; + confPath = names.map(n => join(dirname(p), n)).find(existsSync); + if (!confPath) throw new Error(`--tauri ${hint}: no tauri.conf.json next to this overlay (the base config it is merged over)`); + } else { + if (/tauri\.conf\.json5?$/.test(p) && existsSync(p)) confPath = p; + else confPath = names.map(n => join(p, n)).find(existsSync); + if (!confPath) throw new Error(`--tauri ${hint}: no tauri.conf.json in it`); + autoOverlay(); + } } else { const hits: string[] = []; const walk = (dir: string, d: number): void => { @@ -43,16 +75,22 @@ export function findTauri(root: string, hint?: string): TauriProject { walk(root, 4); if (hits.length !== 1) throw new Error(hits.length ? `several Tauri projects (${hits.map(h => relative(root, h)).join(', ')}): pass --tauri ` : 'no tauri.conf.json found: pass --tauri '); confPath = hits[0]!; + autoOverlay(); } let conf: Record; try { conf = JSON.parse(readFileSync(confPath, 'utf8')); } catch (e) { throw new Error(`${confPath}: not valid JSON (${(e as Error).message}); tauri.conf.json5 / Tauri.toml are not read, use tauri.conf.json`); } + if (overlayPath) { + let patch: unknown; + try { patch = JSON.parse(readFileSync(overlayPath, 'utf8')); } catch (e) { throw new Error(`${overlayPath}: not valid JSON (${(e as Error).message})`); } + conf = mergePatch(conf, patch) as Record; + } const mac = conf.bundle?.macOS ?? {}; const productName = String(conf.productName ?? ''); if (!productName) throw new Error(`${confPath}: productName is missing`); const dir = dirname(confPath); return { dir, confPath, cwd: dirname(dir), productName, identifier: String(conf.identifier ?? ''), version: typeof conf.version === 'string' ? conf.version : undefined, category: typeof conf.bundle?.category === 'string' ? conf.bundle.category : undefined, minOs: typeof mac.minimumSystemVersion === 'string' ? mac.minimumSystemVersion : undefined, - entitlements: typeof mac.entitlements === 'string' ? join(dir, mac.entitlements) : undefined, conf }; + entitlements: typeof mac.entitlements === 'string' ? join(dir, mac.entitlements) : undefined, ...(overlayPath ? { overlayPath, overlayAuto } : {}), conf }; } /** Tauri's category names (and Apple's public.app-category.* ids) -> the LSApplicationCategoryType Tauri writes; null when unknown. */ @@ -79,6 +117,43 @@ export function masEntitlements(o: { teamId: string; bundleId: string; existing? }; } +/** Where the entitlements of a Mac App Store build come from, first hit wins: `--entitlements `, the config's + * `apple.macos.entitlements`, the merged Tauri config's `bundle.macOS.entitlements`. Paths are already absolute. Pure. */ +export function entitlementsSource(o: { flag?: string; config?: string; tauri?: string }): { file: string; source: string } | null { + if (o.flag) return { file: o.flag, source: '--entitlements' }; + if (o.config) return { file: o.config, source: 'apple.macos.entitlements' }; + if (o.tauri) return { file: o.tauri, source: 'tauri config bundle.macOS.entitlements' }; + return null; +} + +/** Entitlements that App Store Connect refuses in a Mac App Store build (a Developer ID / development leftover). */ +export const MAS_REFUSED_KEYS: Record = { + 'com.apple.security.get-task-allow': 'a development entitlement, App Store Connect refuses it', + 'com.apple.security.cs.disable-library-validation': 'a Developer ID hardened-runtime exception, App Store Connect refuses it', +}; + +/** Hardened-runtime exceptions and Developer ID habits that do not belong in a Mac App Store build. App Review may + * question them or the sandbox ignores them, but they are not an automatic upload refusal: a warning, not a stop. */ +export const MAS_SUSPECT_KEYS: Record = { + 'com.apple.security.cs.allow-jit': 'hardened-runtime exception for Developer ID builds; App Review asks why a sandboxed app needs it', + 'com.apple.security.cs.allow-unsigned-executable-memory': 'hardened-runtime exception for Developer ID builds; App Review asks why', + 'com.apple.security.cs.disable-executable-page-protection': 'hardened-runtime exception for Developer ID builds', + 'com.apple.security.cs.allow-dyld-environment-variables': 'hardened-runtime exception for Developer ID builds', + 'com.apple.security.cs.debugger': 'hardened-runtime exception for Developer ID builds (a debugger cannot be sandboxed)', + 'com.apple.security.automation.apple-events': 'needs NSAppleEventsUsageDescription in Info.plist and a reason in the review notes; usually a Developer ID leftover', +}; + +/** The entitlements of a Mac App Store build against the two lists above (a key set to false is fine). Pure. */ +export function masEntitlementProblems(ent: Record | null | undefined): { errors: string[]; warnings: string[] } { + const errors: string[] = [], warnings: string[] = []; + for (const [k, v] of Object.entries(ent ?? {})) { + if (v === false) continue; + if (MAS_REFUSED_KEYS[k]) errors.push(`${k} is set: ${MAS_REFUSED_KEYS[k]}`); + else if (MAS_SUSPECT_KEYS[k]) warnings.push(`${k} is set: ${MAS_SUSPECT_KEYS[k]}`); + } + return { errors, warnings }; +} + export const entitlementsXml = (e: Record): string => toXmlPlist(e); /** Keys of an entitlements plist (XML or what `codesign -d --entitlements :-` prints). */ @@ -170,7 +245,7 @@ export const leakNeedles = (home: string, user: string, root: string): string[] export interface AppFacts { info: Record; entitlements: Record | null; hasProfile: boolean; leaks: Leak[] } /** Problems (stop) and warnings of a built .app before it goes into a .pkg. Pure. */ -export function appProblems(f: AppFacts, o: { bundleId: string; version: string; build: string; signed: boolean; pathFree: boolean }): { errors: string[]; warnings: string[] } { +export function appProblems(f: AppFacts, o: { bundleId: string; version: string; build: string; signed: boolean; pathFree: boolean; allowEntitlements?: boolean }): { errors: string[]; warnings: string[] } { const errors: string[] = [], warnings: string[] = []; const s = (k: string): string | undefined => (typeof f.info[k] === 'string' ? f.info[k] as string : undefined); if (s('CFBundleIdentifier') !== o.bundleId) errors.push(`CFBundleIdentifier is ${s('CFBundleIdentifier') ?? 'missing'}, not ${o.bundleId}`); @@ -184,7 +259,9 @@ export function appProblems(f: AppFacts, o: { bundleId: string; version: string; else { for (const k of MAS_REQUIRED) if (f.entitlements[k] === undefined) errors.push(`entitlement ${k} is missing`); if (f.entitlements['com.apple.security.app-sandbox'] !== true) errors.push('com.apple.security.app-sandbox is not true'); - if (f.entitlements['com.apple.security.get-task-allow'] === true) errors.push('com.apple.security.get-task-allow is true: a development entitlement, App Store Connect refuses it'); + const ep = masEntitlementProblems(f.entitlements); + (o.allowEntitlements ? warnings : errors).push(...ep.errors); + warnings.push(...ep.warnings); } if (!f.hasProfile) errors.push('Contents/embedded.provisionprofile is missing: a Mac App Store app needs its provisioning profile inside (Tauri bundle.macOS.files)'); } @@ -230,7 +307,7 @@ export interface MasPlanInput { export function masSteps(i: MasPlanInput): { tauri: string[]; productbuild: string[]; codesignVerify: string[]; pkgCheck: string[] } { const appPath = join(i.tauri.dir, 'target', i.target, 'release', 'bundle', 'macos', `${i.tauri.productName}.app`); return { - tauri: [...i.tauriCmd, 'build', '--bundles', 'app', '--target', i.target, '--config', i.configFile], + tauri: [...i.tauriCmd, 'build', '--bundles', 'app', '--target', i.target, ...(i.tauri.overlayPath ? ['--config', i.tauri.overlayPath] : []), '--config', i.configFile], codesignVerify: ['codesign', '--verify', '--deep', '--strict', '--verbose=2', appPath], productbuild: ['productbuild', '--component', appPath, '/Applications', ...(i.unsigned || !i.installerIdentity ? [] : ['--sign', i.installerIdentity]), i.pkg], pkgCheck: ['pkgutil', '--check-signature', i.pkg], @@ -271,29 +348,51 @@ export function readAppInfo(appDir: string): Record { return parsePlist(readFileSync(p)) as Record; } +/** What bounds and reports the .pkg reading (tests inject a fake spawnSync and short limits). */ +export interface PkgInfoOptions { say?: (msg: string) => void; spawnSync?: typeof rt.spawnSync; checkMs?: number; expandMs?: number } +/** pkgutil --check-signature evaluates the certificate chain (revocation lookups over the network): never unbounded. */ +export const PKG_CHECK_MS = 90_000; +/** pkgutil --expand copies the whole archive to a temp folder: minutes for a big app, never unbounded. */ +export const PKG_EXPAND_MS = 5 * 60_000; + /** bundle id, version and build of a .pkg: pkgutil --expand on a Mac (PackageInfo), else the .json next to it that - * `apple build --platform macos` wrote. Refuses an unsigned or wrongly signed .pkg when pkgutil is there. */ -export function pkgInfo(pkg: string, expectBundleId?: string): { bundleId: string; version: string; build: string } { - if (rt.which('pkgutil')) { - const sig = parsePkgSignature(rt.spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe' }).stdout.toString()); - const bad = pkgProblems(sig); - if (bad.length) throw new Error(`${basename(pkg)}: not uploaded:\n${bad.map(x => ` ${x}`).join('\n')}`); - const tmp = join(process.env.TMPDIR ?? '/tmp', `sb-pkg-${process.pid}-${Date.now()}`); - try { - if (rt.spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe' }).exitCode === 0) { - for (const d of readdirSync(tmp)) { - const p = join(tmp, d, 'PackageInfo'); - const info = existsSync(p) ? parsePackageInfo(readFileSync(p, 'utf8')) : null; - if (info && (!expectBundleId || info.bundleId === expectBundleId)) return info; - } - } - } finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* temp */ } } - } + * `apple build --platform macos` wrote. Refuses an unsigned or wrongly signed .pkg when pkgutil is there. Both pkgutil + * calls have a timeout (no stdin, so no prompt) and say what they do first; one that times out falls back to the + * .json file, else it fails with the reason (a silent hang over SSH, 10 min, was this read). */ +export function pkgInfo(pkg: string, expectBundleId?: string, o: PkgInfoOptions = {}): { bundleId: string; version: string; build: string } { + const say = o.say ?? (() => {}); + const spawnSync = o.spawnSync ?? rt.spawnSync; const side = `${pkg}.json`; + let timedOut = ''; + if (rt.which('pkgutil') || o.spawnSync) { + const checkMs = o.checkMs ?? PKG_CHECK_MS, expandMs = o.expandMs ?? PKG_EXPAND_MS; + say(`checking the signature of ${basename(pkg)} (pkgutil --check-signature, up to ${Math.round(checkMs / 1000)} s)`); + const chk = spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe', timeout: checkMs }); + if (chk.exitCode === null && chk.signalCode) timedOut = `pkgutil --check-signature gave no answer in ${Math.round(checkMs / 1000)} s (it checks the certificate chain over the network)`; + else { + const bad = pkgProblems(parsePkgSignature(chk.stdout.toString())); + if (bad.length) throw new Error(`${basename(pkg)}: not uploaded:\n${bad.map(x => ` ${x}`).join('\n')}`); + const tmp = join(process.env.TMPDIR ?? '/tmp', `sb-pkg-${process.pid}-${Date.now()}`); + say(`reading bundle id, version and build (pkgutil --expand, up to ${Math.round(expandMs / 1000)} s; a big package takes a while)`); + try { + const ex = spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe', timeout: expandMs }); + if (ex.exitCode === null && ex.signalCode) timedOut = `pkgutil --expand gave no answer in ${Math.round(expandMs / 1000)} s`; + else if (ex.exitCode === 0) { + for (const d of readdirSync(tmp)) { + const p = join(tmp, d, 'PackageInfo'); + const info = existsSync(p) ? parsePackageInfo(readFileSync(p, 'utf8')) : null; + if (info && (!expectBundleId || info.bundleId === expectBundleId)) return info; + } + } + } finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* temp */ } } + } + } + if (timedOut) say(`${timedOut}: using ${basename(side)} (written by apple build) instead`); if (existsSync(side)) { const j = JSON.parse(readFileSync(side, 'utf8')) as { bundleId?: string; version?: string; build?: string; unsigned?: boolean }; if (j.unsigned) throw new Error(`${basename(pkg)} was built with --unsigned: the Mac App Store refuses it`); if (j.bundleId && j.version && j.build) return { bundleId: j.bundleId, version: j.version, build: j.build }; } + if (timedOut) throw new Error(`${basename(pkg)}: ${timedOut}, and there is no ${basename(side)} file from apple build to read instead: run it in the Mac's logged-in GUI session, or check the Mac's network`); throw new Error(`${basename(pkg)}: cannot read bundle id / version / build (needs pkgutil on a Mac, or the ${basename(side)} file apple build wrote)`); } diff --git a/cli/lib/node-packages.ts b/cli/lib/node-packages.ts index ac44c79..ba58bb9 100644 --- a/cli/lib/node-packages.ts +++ b/cli/lib/node-packages.ts @@ -3,8 +3,9 @@ // folders (package.json "workspaces", npm / yarn / pnpm-style array or { packages }, as Bun reads them) and local // SwiftPM packages (`.package(path:)` in a Package.swift, e.g. Capacitor 8's CapApp-SPM). Used by `privacy-manifest` // and `scan`. Read-only; never throws. -import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { type Dirent, existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; +import { skipDir } from './walk-skip'; interface PkgJson { name?: string; dependencies?: Record; workspaces?: string[] | { packages?: string[] } } const readJson = (p: string): PkgJson | null => { try { return JSON.parse(readFileSync(p, 'utf8')) as PkgJson; } catch { return null; } }; @@ -67,9 +68,10 @@ export function localSwiftPackages(dir: string): string[] { const skip = new Set(['Pods', 'build', 'DerivedData', 'node_modules', '.build', 'SourcePackages', '.git', 'xcuserdata']); const walk = (d: string, depth: number) => { if (depth > 4) return; - let names: string[] = []; - try { names = readdirSync(d); } catch { return; } - for (const n of names) { + let ents: Dirent[] = []; + try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; } + for (const e of ents) { + const n = e.name; if (skip.has(n)) continue; const p = join(d, n); if (n === 'Package.swift') { @@ -79,7 +81,7 @@ export function localSwiftPackages(dir: string): string[] { const target = resolve(d, m[1]!); if (isDir(target)) out.push(target); } - } else if (isDir(p) && !/\.(xc)?framework$|\.xcassets$/.test(n)) walk(p, depth + 1); + } else if ((e.isDirectory() || (e.isSymbolicLink() && isDir(p))) && !/\.(xc)?framework$|\.xcassets$/.test(n) && !skipDir(dir, p, n)) walk(p, depth + 1); } }; walk(dir, 0); diff --git a/cli/lib/path-free.ts b/cli/lib/path-free.ts new file mode 100644 index 0000000..21b5f95 --- /dev/null +++ b/cli/lib/path-free.ts @@ -0,0 +1,103 @@ +// Path-free iOS builds: no home folder, user name or checkout path inside the app binary or the archive's debug +// symbols (a privacy gate over a shared release folder found hundreds of hits). Used by `apple build --path-free`. +// - derivedDataFor: DerivedData per output folder (hermetic), not ~/Library/Developer/Xcode/DerivedData +// - pathFreeSettings: xcodebuild build settings with Swift / clang prefix maps (dSYM and Symbols stay, paths are remapped) +// - scanArchiveLeaks / leakProblem: scan the archive's .app, dSYMs and Symbols for the home folder and the user name +// Pure except scanArchiveLeaks (bounded file reads). +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { posix } from 'node:path'; + +const trim = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, '') : p); + +/** DerivedData folder of a build: --derived-data, else apple.build.derivedData, else /DerivedData when the output + * folder was chosen (--out / apple.build.out), else null (Xcode's default under ~/Library). */ +export function derivedDataFor(o: { flag?: string; config?: string; out: string; outChosen: boolean; resolve: (p: string) => string }): string | null { + const given = o.flag ?? o.config; + if (given) return o.resolve(given); + return o.outChosen ? posix.join(o.out, 'DerivedData') : null; +} + +/** True when `dir` is not inside `home`. */ +export const outsideHome = (dir: string, home: string): boolean => { const h = trim(home); return !!h && dir !== h && !dir.startsWith(`${h}/`); }; + +export interface PathFreeInput { home: string; root: string; out: string; derivedData?: string | null } + +/** [from, to] prefix maps: the home folder, the checkout, the output folder and DerivedData, longest (most specific) first. + * swiftc applies the FIRST matching map, clang the LAST: pathFreeSettings reverses the list for clang. */ +export function prefixMaps(i: PathFreeInput): [string, string][] { + const all: [string, string][] = [[trim(i.home), '/home'], [trim(i.root), '.'], [trim(i.out), '/out']]; + if (i.derivedData) all.push([trim(i.derivedData), '/derived']); + const seen = new Set(); + return all.filter(([from]) => from.length > 1 && !seen.has(from) && seen.add(from)).sort((a, b) => b[0].length - a[0].length); +} + +const q = (s: string): string => (/[\s"'$]/.test(s) ? `"${s.replace(/(["\$])/g, '\$1')}"` : s); + +/** xcodebuild build settings that remap every local path in Swift, C, C++ and Objective-C output and in DWARF. The + * debug format is left alone (dwarf-with-dsym), so symbol upload keeps working. */ +export function pathFreeSettings(i: PathFreeInput): string[] { + const maps = prefixMaps(i); + const swift = maps.flatMap(([f, t]) => ['-debug-prefix-map', q(`${f}=${t}`), '-file-prefix-map', q(`${f}=${t}`)]); + const clang = [...maps].reverse().flatMap(([f, t]) => [q(`-ffile-prefix-map=${f}=${t}`)]); + // SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO: Xcode embeds the compiler's include paths, module cache and -ffile-compilation-dir + // (the checkout) in the binary's __swift_ast section; no prefix map reaches it. Only lldb's Swift expression evaluation + // uses it; crash symbolication (the dSYM's functions and lines) does not. + return ['SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO', `OTHER_SWIFT_FLAGS=$(inherited) ${swift.join(' ')}`, `OTHER_CFLAGS=$(inherited) ${clang.join(' ')}`, `OTHER_CPLUSPLUSFLAGS=$(inherited) ${clang.join(' ')}`]; +} + +export interface PathHit { file: string; kind: string; offset: number } + +/** The needles of a leak scan, named by kind so a report never prints the value: the home folder, /Users// and the + * checkout. DerivedData is no needle: outside the home folder it is not the user's (dsymutil and Xcode record it in the + * dSYM and the __swift_ast section and no prefix map reaches it); inside the home folder the home needle finds it. */ +export function leakNeedles(i: { home: string; user: string; root: string }): { kind: string; value: string }[] { + // the checkout is a needle only when it says something about the person: inside the home folder or naming the user. + // A shared build folder (/Users/Shared/..., /opt/...) recorded by dsymutil and Xcode's module cache is not private + const root = trim(i.root) + '/'; + const personal = root.startsWith(trim(i.home) + '/') || (i.user.length >= 3 && root.includes(`/${i.user}/`)); + const list = [{ kind: 'home folder', value: trim(i.home) + '/' }, { kind: 'user folder', value: `/Users/${i.user}/` }, ...(personal ? [{ kind: 'checkout', value: root }] : [])]; + const seen = new Set(); + return list.filter(n => n.value.length >= 6 && !seen.has(n.value) && seen.add(n.value)); +} + +const MAX_FILE = 600 * 1048576; + +/** Every needle occurrence in the files under `dirs` (all of them are counted; the first `perFile` offsets per file kept). */ +export function scanForPaths(dirs: string[], needles: { kind: string; value: string }[], perFile = 3): { hits: PathHit[]; total: number; files: number } { + const ns = needles.map(n => ({ kind: n.kind, b: Buffer.from(n.value) })); + const hits: PathHit[] = []; + const bad = new Set(); + let total = 0; + const walk = (base: string, d: string): void => { + let names: string[]; try { names = readdirSync(d); } catch { return; } + for (const name of names) { + const p = join(d, name); + let st; try { st = statSync(p); } catch { continue; } + if (st.isDirectory()) { walk(base, p); continue; } + if (!st.isFile() || st.size > MAX_FILE) continue; + const buf = readFileSync(p); + let kept = 0; + for (const { kind, b } of ns) { + for (let at = buf.indexOf(b); at >= 0; at = buf.indexOf(b, at + b.length)) { + total++; bad.add(p); + if (kept++ < perFile) hits.push({ file: relative(base, p).split(sep).join('/'), kind, offset: at }); + } + } + } + }; + for (const d of dirs) if (existsSync(d)) walk(d, d); + return { hits, total, files: bad.size }; +} + +/** What the archive holds that ships or symbolicates: Products (the .app, extensions, frameworks), dSYMs, Symbols. */ +export const archiveScanDirs = (archive: string): string[] => ['Products', 'dSYMs', 'Symbols'].map(x => join(archive, x)); + +/** The refusal text of a path-free build that still has local paths, else null. Counts, kinds, file and offset only. */ +export function leakProblem(r: { hits: PathHit[]; total: number; files: number }, archive: string): string | null { + if (!r.total) return null; + const lines = r.hits.slice(0, 8).map(h => ` ${h.file} @${h.offset}: ${h.kind}`); + return `${r.total} local path${r.total === 1 ? '' : 's'} left in ${r.files} file${r.files === 1 ? '' : 's'} of ${archive} (home folder, user name or checkout):\n${lines.join('\n')}${r.hits.length > 8 ? '\n ...' : ''}\n` + + 'Stopped before the export. Causes: #file / #filePath in Swift sources (no prefix map reaches those literals: use #fileID), a prebuilt framework or Pod that carries its own paths ' + + '(rebuild it path-free), or DerivedData / --out inside the home folder (dsymutil and Xcode record that path: use --out or --derived-data outside it, e.g. a shared folder).'; +} diff --git a/cli/lib/portfolio.ts b/cli/lib/portfolio.ts index ae906e6..8448f5f 100644 --- a/cli/lib/portfolio.ts +++ b/cli/lib/portfolio.ts @@ -161,11 +161,15 @@ export function playReleases(data: unknown): StoreRelease[] { return out; } +/** A completed Play production release the store page does not show yet. The API has no review state: it may be in + * review, or blocked before review (an old build on another track with a policy issue, pitfall 129). Never "In review". */ +export const PLAY_NOT_LIVE = 'Not live yet: in review or blocked before review (the API cannot tell; see Publishing overview)'; + /** Plain words for a Play release's phase, decided from the store page (CLI text; the desktop has the same words in * i18n, apps/desktop/src/ui/kit.tsx phaseLabel). */ export function playReleaseLabel(r: StoreRelease): string { const base = ((): string => { - if (r.phase === 'review' || r.phase === 'sent') return r.managed ? 'In review / ready to publish (managed publishing)' : r.liveVersion ? `In review (live: ${r.liveVersion})` : 'In review'; + if (r.phase === 'review' || r.phase === 'sent') return r.managed ? 'In review / ready to publish (managed publishing)' : r.liveVersion ? `${PLAY_NOT_LIVE} (live: ${r.liveVersion})` : PLAY_NOT_LIVE; if (r.phase === 'released') return "Couldn't check the store page"; if (r.phase === 'live') return 'Live'; if (r.phase === 'rollout') return `Rolling out ${Math.round((r.fraction ?? 0) * 1000) / 10}%`; diff --git a/cli/lib/privacy-files.ts b/cli/lib/privacy-files.ts index 779b942..94a3450 100644 --- a/cli/lib/privacy-files.ts +++ b/cli/lib/privacy-files.ts @@ -2,9 +2,10 @@ // .xcframework, SwiftPM checkouts, Capacitor / Cordova / React Native plugins in node_modules, a built .app's // Frameworks and bundles) and every PrivacyInfo.xcprivacy inside an .ipa or .app (XML or binary). Read-only; the // directory walk is bounded. -import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { type Dirent, readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, join, relative, sep } from 'node:path'; import { nativeIosPlugins } from './node-packages'; +import { skipDir } from './walk-skip'; import { parseBinaryPlist } from './plist'; import { type Manifest, manifestFromPlist, parseManifest, trackingIssue } from './privacy-manifest'; import { zipEntries, zipRead } from './zip'; @@ -63,15 +64,16 @@ export function bundleTrackingIssues(list: BundleManifest[]): string[] { function walk(dir: string, visit: (p: string, isDir: boolean) => boolean | void, depth = 0, budget = { n: 60_000 }): void { if (depth > 16 || budget.n <= 0) return; - let names: string[] = []; - try { names = readdirSync(dir).sort(); } catch { return; } - for (const n of names) { + let ents: Dirent[] = []; + try { ents = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } catch { return; } + for (const e of ents) { if (--budget.n <= 0) return; - const p = join(dir, n); - let st; - try { st = statSync(p); } catch { continue; } - const descend = visit(p, st.isDirectory()); - if (st.isDirectory() && descend !== false) walk(p, visit, depth + 1, budget); + const p = join(dir, e.name); + // the entry type comes with the listing (no stat per file: a stat each made a big tree take minutes on Windows) + let isDir = e.isDirectory(); + if (e.isSymbolicLink()) { try { isDir = statSync(p).isDirectory(); } catch { continue; } } + const descend = visit(p, isDir); + if (isDir && descend !== false) walk(p, visit, depth + 1, budget); } } @@ -102,7 +104,7 @@ export function projectSdkManifests(root: string): SdkManifest[] { try { out.push({ sdk, manifest: manifestFromBytes(relative(root, abs).split(sep).join('/'), readFileSync(abs)) }); } catch { /* unreadable */ } }; walk(root, (p, isDir) => { - if (isDir) return !SKIP.has(basename(p)); + if (isDir) return !SKIP.has(basename(p)) && !skipDir(root, p, basename(p)); if (basename(p) !== 'PrivacyInfo.xcprivacy') return; const sdk = sdkOfProjectPath(relative(root, p).split(sep).join('/')); if (sdk) add(p, sdk); diff --git a/cli/lib/release-kind.ts b/cli/lib/release-kind.ts new file mode 100644 index 0000000..1144500 --- /dev/null +++ b/cli/lib/release-kind.ts @@ -0,0 +1,68 @@ +// Update or first release? The owner's standing rule (docs/bypass.md, "Updates are agent steps"): a new version of an +// app that is ALREADY on the store ships end to end without an owner gate (submit for review, promote to production, +// roll out, release an approved version) with --yes, also in a pipeline. The gate stays for the first-ever release of an +// app (and, in their own commands, removal from sale and live price changes). +// The kind comes from the store, never from the config: +// Google Play the production track holds a completed / inProgress / halted release (the app was published) +// App Store the platform has a version that is or was on sale (READY_FOR_SALE, READY_FOR_DISTRIBUTION, +// REPLACED_WITH_NEW_VERSION) +// A first release with --yes (nobody to answer a prompt) is refused with exit 3 `OWNER_STEP` unless the owner approved +// it: `--first-release` after the owner's yes in chat, or bypass mode (docs/bypass.md). In a terminal without --yes the +// normal confirmation prompt is the owner's approval. +import { bypassOn } from './bypass'; +import type { Ctx } from './ctx'; +import { declined } from './ctx'; +import { log } from './log'; +import { SHIP_EXIT, ShipStepError, shipRun } from './ship-step'; + +export type ReleaseKind = 'update' | 'first'; +export interface KindAnswer { kind: ReleaseKind; why: string } + +/** Play: the production track's releases say whether the app was ever published. Pure. */ +export function playReleaseKind(production: { status?: string; versionCodes?: string[] }[] | undefined): KindAnswer { + const live = (production ?? []).filter(r => ['completed', 'inProgress', 'halted'].includes(String(r.status))); + if (live.length) return { kind: 'update', why: `the production track holds ${live.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`).join('; ')}` }; + const drafts = (production ?? []).length; + return { kind: 'first', why: drafts ? 'the production track has only draft releases: the app was never published' : 'the production track is empty: the app was never published' }; +} + +/** App Store version states that prove the app was on sale. */ +export const APPLE_LIVE_STATES = ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_VERSION'] as const; + +/** App Store: the versions of one platform say whether the app was ever on sale. Pure. */ +export function appleReleaseKind(versions: { versionString?: string; state: string }[]): KindAnswer { + const live = versions.filter(v => (APPLE_LIVE_STATES as readonly string[]).includes(v.state)); + if (live.length) return { kind: 'update', why: `${live.map(v => `${v.versionString ?? '?'} ${v.state}`).join(', ')} on this platform` }; + return { kind: 'first', why: 'no version of this app was ever on sale on this platform' }; +} + +export interface GateInput { kind: ReleaseKind; yes: boolean; bypass: boolean; approved: boolean } +/** Whether the step may run. Updates always; a first release only with a person answering a prompt (no --yes), the + * owner's `--first-release`, or bypass mode. Pure. */ +export function firstReleaseAllowed(g: GateInput): boolean { + return g.kind === 'update' || !g.yes || g.approved || g.bypass; +} + +export const firstReleaseMessage = (store: 'play' | 'apple', what: string): string => + `first release of this app (${store === 'play' ? 'Google Play' : 'App Store'}): ${what} is the owner's step, approved once. Updates of an app that is already live need no approval. ` + + 'After the owner\'s yes: re-run with --first-release (or turn on bypass mode: storebound settings bypass on)'; + +/** + * Applies the policy to a real run. Returns true when the step may go on. A refusal is exit 3: error.code OWNER_STEP + * under the ship-step contract (with the console deep link), the usual "declined" exit 3 otherwise. + */ +export function enforceFirstRelease(ctx: Ctx, k: KindAnswer, o: { store: 'play' | 'apple'; what: string; consoleUrl?: string }): boolean { + const a = ctx.args; + const bypass = bypassOn(); + // the desktop's owner action: the owner pressed the button of a native dialog that showed the dry run (cli/lib/ctx.ts confirm) + const approved = a.has('first-release') || (process.env.STOREBOUND_SERVE === '1' && process.env.STOREBOUND_OWNER_CONFIRMED === '1'); + if (firstReleaseAllowed({ kind: k.kind, yes: a.has('yes'), bypass, approved })) { + if (k.kind === 'update') log('release', `update of a live app (${k.why}): no owner step needed`); + else log('release', `first release (${k.why}): ${approved ? 'approved by the owner' : bypass ? 'bypass mode' : 'confirmation prompt'}`); + return true; + } + const msg = `${firstReleaseMessage(o.store, o.what)} (${k.why})`; + if (shipRun()) throw new ShipStepError(msg, SHIP_EXIT.owner, 'OWNER_STEP', o.consoleUrl); + declined(msg); + return false; +} diff --git a/cli/lib/release-type.ts b/cli/lib/release-type.ts new file mode 100644 index 0000000..f090f07 --- /dev/null +++ b/cli/lib/release-type.ts @@ -0,0 +1,54 @@ +// How an App Store version goes live after App Review: apple.releaseType in the config, appStoreVersions.releaseType +// on the store. MANUAL leaves an approved version in PENDING_DEVELOPER_RELEASE until someone releases it; AFTER_APPROVAL +// releases it as soon as it is approved; SCHEDULED releases it at earliestReleaseDate at the earliest (Apple: +// https://developer.apple.com/documentation/appstoreconnectapi/appstoreversion/attributes, releaseType, earliestReleaseDate). +// Pure: the commands (apple listing, apple submit, apple release) read the version and send the PATCH. +import type { FieldDiff } from './listing-plan'; + +export const RELEASE_TYPES = ['afterApproval', 'manual', 'scheduled'] as const; +export type ReleaseTypeName = (typeof RELEASE_TYPES)[number]; +export type AscReleaseType = 'AFTER_APPROVAL' | 'MANUAL' | 'SCHEDULED'; + +export const ASC_RELEASE_TYPE: Record = { afterApproval: 'AFTER_APPROVAL', manual: 'MANUAL', scheduled: 'SCHEDULED' }; + +/** Version states in which App Store Connect still takes a release type change: everything before approval. + * Once the version is approved (PENDING_DEVELOPER_RELEASE and later) it is released with `apple release --manual-release`. */ +export const RELEASE_TYPE_EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', + 'READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW']); + +export interface WantedRelease { releaseType: AscReleaseType; earliestReleaseDate?: string } + +/** The release type apple.releaseType asks for, or null when the config does not say (then nothing is touched). */ +export function wantedRelease(apple: { releaseType?: ReleaseTypeName; earliestReleaseDate?: string } | undefined): WantedRelease | null { + if (!apple?.releaseType) return null; + const releaseType = ASC_RELEASE_TYPE[apple.releaseType]; + return releaseType === 'SCHEDULED' && apple.earliestReleaseDate ? { releaseType, earliestReleaseDate: apple.earliestReleaseDate } : { releaseType }; +} + +/** Problems with apple.releaseType / apple.earliestReleaseDate. Pure. */ +export function validateReleaseType(a: { releaseType?: unknown; earliestReleaseDate?: unknown } | undefined): string[] { + if (!a) return []; + const e: string[] = []; + if (a.releaseType !== undefined && !(RELEASE_TYPES as readonly unknown[]).includes(a.releaseType)) e.push(`apple.releaseType: ${RELEASE_TYPES.join(' | ')} (afterApproval releases an approved version at once; manual waits for \`apple release --manual-release\`)`); + if (a.earliestReleaseDate !== undefined) { + if (typeof a.earliestReleaseDate !== 'string' || Number.isNaN(Date.parse(a.earliestReleaseDate))) e.push('apple.earliestReleaseDate: an ISO 8601 date and time, e.g. 2027-03-01T09:00:00Z'); + else if (a.releaseType !== 'scheduled') e.push('apple.earliestReleaseDate: only with apple.releaseType "scheduled"'); + } + if (a.releaseType === 'scheduled' && a.earliestReleaseDate === undefined) e.push('apple.releaseType "scheduled": apple.earliestReleaseDate is required (ISO 8601 date and time)'); + return e; +} + +/** The attribute changes that bring a version to `want` (old -> new), [] when it is there already. A scheduled date is + * compared as an instant. Pure. */ +export function releaseTypeChanges(cur: Record, want: WantedRelease): FieldDiff[] { + const out: FieldDiff[] = []; + const have = cur.releaseType == null ? null : String(cur.releaseType); + if (have !== want.releaseType) out.push({ field: 'releaseType', from: have, to: want.releaseType }); + if (want.releaseType === 'SCHEDULED' && want.earliestReleaseDate) { + const had = cur.earliestReleaseDate == null ? null : String(cur.earliestReleaseDate); + if (had === null || Date.parse(had) !== Date.parse(want.earliestReleaseDate)) out.push({ field: 'earliestReleaseDate', from: had, to: want.earliestReleaseDate }); + } + return out; +} + +export const changeText = (c: FieldDiff[]): string => c.map(x => `${x.field} ${x.from ?? '(none)'} -> ${x.to}`).join(', '); diff --git a/cli/lib/reports.ts b/cli/lib/reports.ts index c922945..0a99dce 100644 --- a/cli/lib/reports.ts +++ b/cli/lib/reports.ts @@ -148,3 +148,95 @@ export const parsePlayCsv = (text: string): Record[] => { const keys = head.map(h => h.trim()); return rest.map(r => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()]))); }; + +// ---- day ranges (a collector reads N days in one JSON) ---- + +const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; +const addDay = (d: string, n: number): string => new Date(Date.parse(`${d}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10); +export const MAX_RANGE_DAYS = 366; + +/** Every date from..to inclusive (YYYY-MM-DD). Pure. */ +export function dateRange(from: string, to: string): string[] { + const out: string[] = []; + for (let d = from; d <= to; d = addDay(d, 1)) out.push(d); + return out; +} + +/** + * --days N (the N days ending yesterday) or --from/--to (inclusive; --to defaults to yesterday): the dates to read, or + * null when none of the three is given. Daily reports of both stores come out the next day, so the newest day may be + * missing; that is reported, not an error. Pure. + */ +export function reportRange(o: { days?: string; from?: string; to?: string }, now = new Date()): { from: string; to: string; dates: string[] } | null { + if (o.days === undefined && o.from === undefined && o.to === undefined) return null; + if (o.days !== undefined && (o.from !== undefined || o.to !== undefined)) throw new Error('--days or --from/--to, not both'); + const yesterday = addDay(now.toISOString().slice(0, 10), -1); + let from: string, to: string; + if (o.days !== undefined) { + const n = Number(o.days); + if (!Number.isInteger(n) || n < 1 || n > MAX_RANGE_DAYS) throw new Error(`--days: 1-${MAX_RANGE_DAYS}`); + to = yesterday; from = addDay(to, -(n - 1)); + } else { + if (o.from === undefined) throw new Error('--to needs --from YYYY-MM-DD'); + to = o.to ?? yesterday; from = o.from; + if (!DAY_RE.test(from) || !DAY_RE.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('--from / --to: YYYY-MM-DD'); + if (from > to) throw new Error('--from is after --to'); + if (dateRange(from, to).length > MAX_RANGE_DAYS) throw new Error(`--from/--to: at most ${MAX_RANGE_DAYS} days`); + } + return { from, to, dates: dateRange(from, to) }; +} + +export interface DaySummary { date: string; rows: number; summary: SummaryRow[]; totals: { currency: string; units: number; amount: number }[] } + +/** The days of a range as the collector reads them: per-day summary and totals, totals over the range per currency + * (never added across currencies), and the dates that had no report. Pure. */ +export function rangeReport(days: DaySummary[], dates: string[]): { days: DaySummary[]; totals: { currency: string; units: number; amount: number }[]; missing: string[] } { + const have = new Set(days.map(d => d.date)); + const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date)); + return { days: sorted, totals: totalsByCurrency(sorted.flatMap(d => d.summary)), missing: dates.filter(d => !have.has(d)) }; +} + +export const rangeCsvRows = (days: DaySummary[]): (string | number)[][] => + [['date', 'key', 'title', 'currency', 'units', 'amount', 'rows'], ...days.flatMap(d => d.summary.map(r => [d.date, r.key, r.title, r.currency, r.units, r.amount, r.rows]))]; + +export interface PlayDaySales { date: string; currency: string; orders: number; refunds: number; itemPrice: number; taxes: number; charged: number; refunded: number; net: number } + +/** + * Play estimated sales rows -> one line per order-charged date and currency of sale. charged = Charged Amount (tax + * included) of the Charged rows, refunded = the Refund rows (always negative), net = charged + refunded; itemPrice and + * taxes are the Charged rows' Item Price and Taxes Collected. Rows outside from..to are left out. Estimated figures + * before Google's service fee: the earnings report (monthly) has the proceeds. Pure. + */ +export function summarizePlaySalesByDay(rows: Record[], from: string, to: string): PlayDaySales[] { + const m = new Map(); + for (const r of rows) { + const date = (r['Order Charged Date'] ?? '').slice(0, 10); + if (!DAY_RE.test(date) || date < from || date > to) continue; + const status = r['Financial Status'] ?? ''; + const refund = /refund|chargeback/i.test(status); + if (!refund && !/^charge/i.test(status)) continue; + const currency = r['Currency of Sale'] ?? ''; + const id = `${date}\u0000${currency}`; + const x = m.get(id) ?? { date, currency, orders: 0, refunds: 0, itemPrice: 0, taxes: 0, charged: 0, refunded: 0, net: 0 }; + const amount = num(r['Charged Amount']); + if (refund) { x.refunds++; x.refunded += -Math.abs(amount); } + else { x.orders++; x.charged += amount; x.itemPrice += num(r['Item Price']); x.taxes += num(r['Taxes Collected']); } + m.set(id, x); + } + return [...m.values()].map(x => ({ ...x, itemPrice: round2(x.itemPrice), taxes: round2(x.taxes), charged: round2(x.charged), refunded: round2(x.refunded), net: round2(x.charged + x.refunded) })) + .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0)); +} + +/** Range totals per currency of the daily Play sales lines. Pure. */ +export function playSalesTotals(days: PlayDaySales[]): { currency: string; orders: number; refunds: number; charged: number; refunded: number; net: number }[] { + const m = new Map(); + for (const d of days) { + const t = m.get(d.currency) ?? { currency: d.currency, orders: 0, refunds: 0, charged: 0, refunded: 0, net: 0 }; + t.orders += d.orders; t.refunds += d.refunds; t.charged = round2(t.charged + d.charged); t.refunded = round2(t.refunded + d.refunded); t.net = round2(t.net + d.net); + m.set(d.currency, t); + } + return [...m.values()].sort((a, b) => (a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0)); +} + +/** The months (YYYY-MM) a date window touches, for the Play sales file names. Pure. */ +export const monthsOfRange = (from: string, to: string): string[] => [...new Set(dateRange(from, to).map(d => d.slice(0, 7)))]; diff --git a/cli/lib/review-details.ts b/cli/lib/review-details.ts index f397e3c..26153b8 100644 --- a/cli/lib/review-details.ts +++ b/cli/lib/review-details.ts @@ -3,14 +3,15 @@ // field takes up to 4000 bytes; a demo account must keep working during the whole review (Guideline 2.1). // https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information // https://developer.apple.com/documentation/appstoreconnectapi/appstorereviewdetail/attributes-data.dictionary -import type { ShipConfig } from './config'; +import { reviewNotesFor, type ShipConfig } from './config'; import { type DemoAccount, maskSecret, maskUser } from './credentials'; export const NOTES_MAX_BYTES = 4000; -/** Notes for the reviewer: apple.info.reviewNotes, then reviewAccess.instructions, then where account deletion is. */ -export function reviewNotes(config: Pick): string { - const parts = [config.apple?.info?.reviewNotes?.trim() ?? '']; +/** Notes for the reviewer: apple.info.reviewNotes (macOS: apple.macos.reviewNotes when set), then reviewAccess.instructions, + * then where account deletion is. */ +export function reviewNotes(config: Pick, platform?: string): string { + const parts = [reviewNotesFor(config, platform)?.trim() ?? '']; const ins = config.reviewAccess?.instructions?.trim(); if (ins && !parts[0]!.includes(ins)) parts.push(`Sign-in: ${ins}`); const del = config.accounts?.enabled ? config.accounts.inApp?.trim() : ''; @@ -29,8 +30,8 @@ export interface ReviewDetailPlan { * (throws when it is missing, so a submission never goes out with "sign-in required" and no account); without it the * demo fields are cleared. */ -export function reviewDetailPlan(config: Pick, contact: Record, demo: DemoAccount | null): ReviewDetailPlan { - const notes = reviewNotes(config); +export function reviewDetailPlan(config: Pick, contact: Record, demo: DemoAccount | null, platform?: string): ReviewDetailPlan { + const notes = reviewNotes(config, platform); const bytes = new TextEncoder().encode(notes).length; if (bytes > NOTES_MAX_BYTES) throw new Error(`review notes are ${bytes} bytes; App Store Connect takes ${NOTES_MAX_BYTES}`); const required = !!config.reviewAccess?.signInRequired; diff --git a/cli/lib/review-submit.ts b/cli/lib/review-submit.ts index d411190..d7b9585 100644 --- a/cli/lib/review-submit.ts +++ b/cli/lib/review-submit.ts @@ -286,7 +286,7 @@ export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep, } for (const it of plan.items) rows.push({ step: 'item', what: it.label, now: it.devRejected ? 'DEVELOPER_REJECTED' : '-', action: addAction(it) }); if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : `left as it is: ${EMPTY_DRAFT}` }); - if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'owner step (--yes); retried while Apple says "not ready yet" and no item is left REJECTED' }); + if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'send with --yes (update of an app on sale: no owner step; the first-ever release: the owner approves it once); retried while Apple says "not ready yet" and no item is left REJECTED' }); return rows; } diff --git a/cli/lib/scan/project.ts b/cli/lib/scan/project.ts index f0b8f8b..01dc57a 100644 --- a/cli/lib/scan/project.ts +++ b/cli/lib/scan/project.ts @@ -3,9 +3,10 @@ // Xcode INFOPLIST_KEY_* build settings, Expo app.json, Unity ProjectSettings and Godot export presets), entitlements, // privacy manifests, the Android manifest (permissions, services, target SDK) and the app's own source files. // Tolerant on purpose: an unreadable or odd file is skipped, never an exception. -import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { type Dirent, readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, dirname, extname, join, relative, sep } from 'node:path'; import { resolvePackage } from '../node-packages'; +import { skipDir } from '../walk-skip'; import { isBuildCopyPath, type Manifest, parseManifest } from '../privacy-manifest'; import { type DepRef, type Eco, matchesSdk, SDKS, type SdkDef } from './sdks'; import { exportedIpas, ipaInfo } from '../xcode'; @@ -104,33 +105,32 @@ const SRC_EXT = new Set(['.swift', '.m', '.mm', '.h', '.kt', '.java', '.dart', ' const MAX_FILES = 25_000; const MAX_TEXT = 1_500_000; -/** A linked git worktree: its .git is a file pointing into /.git/worktrees/ (a submodule's points into modules/). */ -function isWorktree(dir: string): boolean { - try { return /^gitdir:.*[\\/]worktrees[\\/]/m.test(readFileSync(join(dir, '.git'), 'utf8')); } catch { return false; } -} - /** Every file under root (relative paths), skipping build output, dependency folders and VCS data. Native libraries * (.so) of any size also go into `bins` when given (they are not read, only listed). */ export function listFiles(root: string, bins?: string[]): string[] { const out: string[] = []; const walk = (dir: string, depth: number) => { if (depth > 14 || out.length >= MAX_FILES) return; - let names: string[]; - try { names = readdirSync(dir); } catch { return; } - for (const n of names.sort()) { + let ents: Dirent[]; + try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of ents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { + const n = e.name; if (SKIP_DIRS.has(n) || n === '.DS_Store') continue; const p = join(dir, n); - let st; - try { st = statSync(p); } catch { continue; } - if (st.isDirectory()) { + // the entry type comes with the listing; only files (for their size) and links get a stat + let isDir = e.isDirectory(); + if (e.isSymbolicLink()) { try { isDir = statSync(p).isDirectory(); } catch { continue; } } + if (isDir) { // compiled frameworks are SDK binaries, not app code if (/\.(xc)?framework$|\.xcassets$|\.bundle$|\.app$|\.dSYM$/.test(n)) continue; - // a git worktree inside the project (another checkout of the same repo; submodules stay in) - if (isWorktree(p)) continue; + // dot folders, git worktrees (other checkouts of the same repo; submodules stay in), git-ignored output + if (skipDir(root, p, n)) continue; walk(p, depth + 1); } else { if (bins && n.endsWith('.so')) bins.push(relative(root, p).split(sep).join('/')); - if (st.size <= MAX_TEXT) out.push(relative(root, p).split(sep).join('/')); + let size = 0; + try { size = statSync(p).size; } catch { continue; } + if (size <= MAX_TEXT) out.push(relative(root, p).split(sep).join('/')); } if (out.length >= MAX_FILES) return; } diff --git a/cli/lib/ship-step.ts b/cli/lib/ship-step.ts index af8e3da..8681609 100644 --- a/cli/lib/ship-step.ts +++ b/cli/lib/ship-step.ts @@ -29,10 +29,11 @@ export const SHIP_STEPS: Record = { 'play build': 'play.bundle-explorer', 'play upload': 'play.releases', 'play release': 'play.releases', - 'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review', + 'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review', 'apple release': 'asc.version', }; /** The final result: the contract keys, then the command's own --json fields that do not clash with them (so readers @@ -297,6 +299,7 @@ const VERSION_FLAGS: Record typeof g !== 'string' || !/^group\.[A-Za-z0-9.-]+$/.test(g)))) e.push(`apple.signing.extensions ${x.bundleId}: appGroups are ids like group.com.example.app`); + } for (const b of s.extraBundleIds ?? []) if (!/^[A-Za-z0-9.-]+$/.test(b)) e.push(`apple.signing.extraBundleIds: "${b}" is not a bundle id`); return e; } @@ -91,7 +113,7 @@ export interface CertChoice { cert: ApiCert | null; why: string; usable: ApiCert /** A certificate is usable when it is of the wanted type, active, valid for 30 more days and this machine holds its * private key (a key file in .storebound/signing or a code signing identity in the keychain). Newest first. */ -export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: ApiCert) => boolean, now = Date.now()): CertChoice { +export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: ApiCert) => boolean, now = Date.now(), platform: string = process.platform): CertChoice { const ofType = certs.filter(c => c.attributes.certificateType === type && c.attributes.activated !== false); const valid = ofType.filter(c => Date.parse(c.attributes.expirationDate ?? '') > now); const expiring = valid.filter(c => Date.parse(c.attributes.expirationDate!) - now < 30 * 86_400_000); @@ -99,7 +121,9 @@ export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: Ap const withoutKey = valid.filter(c => !hasKey(c)); const cert = usable[0] ?? null; const why = cert ? `using ${cert.attributes.name ?? cert.attributes.displayName ?? type} (expires ${cert.attributes.expirationDate?.slice(0, 10)})` - : withoutKey.length ? `${withoutKey.length} ${type} certificate(s) on the team, but this machine has none of their private keys: a new one is needed (or import the .p12 from the Mac that made it)` + : withoutKey.length ? `${withoutKey.length} ${type} certificate(s) on the team, but this machine has none of their private keys: ${platform === 'darwin' + ? 'a new one is needed (or import the .p12 from the Mac that made it)' + : 'this machine cannot build or sign for Apple. The keys live on the Mac that made the certificates: build there (e.g. over ssh to that Mac). Do not create another certificate, Apple caps them per team'}` : `no valid ${type} certificate`; return { cert, why, usable, withoutKey, expiring }; } @@ -158,19 +182,20 @@ export const profileName = (bundleId: string, type: ProfileType, date = new Date export interface ProfilePlan { reuse: ApiProfile | null; create: Req | null; why: string } /** Reuse an ACTIVE profile of the type that holds the certificate and is valid for 14 more days; else create one. */ -export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number }): ProfilePlan { +export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number; /** make a new profile even when one is reusable (after an App Group was ticked in the portal) */ refresh?: boolean }): ProfilePlan { const now = o.now ?? Date.now(); const ok = existing.filter(p => p.attributes.profileType === o.type && p.attributes.profileState === 'ACTIVE' && Date.parse(p.attributes.expirationDate ?? '') - now > 14 * 86_400_000 && (p.certIds ?? []).includes(o.certId)) .sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!)); - if (ok[0]) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` }; + if (ok[0] && !o.refresh) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` }; const stale = existing.filter(p => p.attributes.profileType === o.type && !p.managed); const managed = existing.filter(p => p.attributes.profileType === o.type && p.managed); const why = [ stale.length ? `${stale.length} ${o.type} profile(s), none active with this certificate (states: ${[...new Set(stale.map(p => p.attributes.profileState))].join(', ')})` : '', managed.length ? `${managed.length} Xcode-managed (automatic signing; not readable through the API, not reused)` : '', ].filter(Boolean).join('; ') || `no ${o.type} profile`; - return { reuse: null, why, create: { + const why2 = ok[0] ? `--refresh-profile: a new profile instead of "${ok[0].attributes.name}"` : why; + return { reuse: null, why: why2, create: { method: 'POST', path: '/v1/profiles', note: `create ${o.type} profile for ${o.bundleId}`, body: { data: { type: 'profiles', attributes: { name: profileName(o.bundleId, o.type, new Date(now)), profileType: o.type }, relationships: { bundleId: one('bundleIds', o.bundleIdId), certificates: many('certificates', [o.certId]) } } }, diff --git a/cli/lib/targets.ts b/cli/lib/targets.ts new file mode 100644 index 0000000..227d3b4 --- /dev/null +++ b/cli/lib/targets.ts @@ -0,0 +1,184 @@ +// Signable targets of an Xcode project (the app, app extensions such as a share extension or widget, the watch app, an +// App Clip), read from project.pbxproj: product type, PRODUCT_BUNDLE_IDENTIFIER and CODE_SIGN_ENTITLEMENTS of the +// Release configuration. Pure parsing; the fs side (entitlements files, config override) is cli/apple/signing.ts. +// Also the App Group check: Apple's API can switch the APP_GROUPS capability on for a bundle id but cannot assign a +// group container to it, so a profile carries `com.apple.security.application-groups` only when the group was ticked in +// the developer portal; comparing the target's entitlements with the profile's entitlements finds the gap offline. +export type TargetKind = 'app' | 'extension' | 'watch' | 'clip'; + +export interface PbxTarget { name: string; productType: string; kind: TargetKind; /** null: not resolvable (set in an xcconfig or a variable) */ bundleId: string | null; unresolved?: string; entitlements: string | null } + +/** Product types that are code signed with their own bundle id and provisioning profile. Frameworks, bundles, test + * bundles, tools and libraries are not. */ +export function targetKind(productType: string): TargetKind | null { + const m = /^com\.apple\.product-type\.(.+)$/.exec(productType); + const t = m?.[1]; + if (!t) return null; + if (t === 'application.on-demand-install-capable') return 'clip'; + if (/^application\.watchapp/.test(t) || /^watchkit2?-extension$/.test(t)) return 'watch'; + if (t === 'application') return 'app'; + if (/^(app-extension(\..+)?|extensionkit-extension|tv-app-extension)$/.test(t)) return 'extension'; + return null; +} + +/** id -> body of every `<24 hex id> /* comment *\/ = { ... };` object (brace matched, quoted strings skipped). */ +function pbxObjects(pbx: string): Map { + const out = new Map(); + const re = /^[ \t]*([0-9A-Fa-f]{24})(?:[ \t]*\/\*[\s\S]*?\*\/)?[ \t]*=[ \t]*\{/gm; + for (let m = re.exec(pbx); m; m = re.exec(pbx)) { + const open = m.index + m[0].length - 1; + let depth = 0, q = false, i = open; + for (; i < pbx.length; i++) { + const ch = pbx[i]; + if (q) { if (ch === '\\') i++; else if (ch === '"') q = false; continue; } + if (ch === '"') q = true; + else if (ch === '{') depth++; + else if (ch === '}' && --depth === 0) break; + } + out.set(m[1]!, pbx.slice(open + 1, i)); + re.lastIndex = i; + } + return out; +} + +const unquote = (s: string): string => { const t = s.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; }; +const field = (body: string, name: string): string | null => { + const m = new RegExp(`(?:^|[;\\s])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*(?:/\\*[\\s\\S]*?\\*/)?\\s*;`).exec(body); + return m ? unquote(m[1]!) : null; +}; + +function buildSettings(body: string): Record { + const at = body.search(/buildSettings\s*=\s*\{/); + if (at < 0) return {}; + const inner = body.slice(body.indexOf('{', at) + 1); + const out: Record = {}; + for (const m of inner.matchAll(/^[ \t]*("?[A-Za-z_][A-Za-z0-9_\[\]=*]*"?)[ \t]*=[ \t]*("(?:[^"\\]|\\.)*"|\([^)]*\)|[^;]*);/gm)) out[unquote(m[1]!)] = unquote(m[2]!); + return out; +} + +/** $(VAR) / ${VAR} from the target's own settings, TARGET_NAME and PRODUCT_NAME; a modifier (:rfc1034identifier) + * makes the value an identifier. Anything still containing $( afterwards is unresolved. */ +export function resolveSetting(value: string, vars: Record): string { + let v = value; + for (let n = 0; n < 5 && /\$[({]/.test(v); n++) { + v = v.replace(/\$[({]([A-Za-z0-9_]+)(?::([a-z0-9]+))?[)}]/g, (all, name: string, mod?: string) => { + const r = vars[name]; + if (r === undefined || r === `$(${name})`) return all; + return mod === 'rfc1034identifier' || mod === 'identifier' ? r.replace(/[^A-Za-z0-9.-]/g, '-') : r; + }); + } + return v; +} + +/** The signable targets of one project.pbxproj, in file order. */ +export function pbxTargets(pbx: string): PbxTarget[] { + const objs = pbxObjects(pbx); + const out: PbxTarget[] = []; + for (const body of objs.values()) { + if (field(body, 'isa') !== 'PBXNativeTarget') continue; + const productType = field(body, 'productType') ?? ''; + const kind = targetKind(productType); + if (!kind) continue; + const name = field(body, 'name') ?? field(body, 'productName') ?? '?'; + const listBody = objs.get((field(body, 'buildConfigurationList') ?? '').split(/\s/)[0]!); + const ids = [...(/buildConfigurations\s*=\s*\(([\s\S]*?)\)/.exec(listBody ?? '')?.[1] ?? '').matchAll(/([0-9A-Fa-f]{24})/g)].map(m => m[1]!); + const cfgs = ids.map(id => objs.get(id)).filter((b): b is string => !!b); + const cfg = cfgs.find(c => field(c, 'name') === 'Release') ?? cfgs[cfgs.length - 1]; + const s = cfg ? buildSettings(cfg) : {}; + const vars: Record = { ...s, TARGET_NAME: name, PRODUCT_NAME: resolveSetting(s.PRODUCT_NAME ?? name, { TARGET_NAME: name }) }; + const raw = s.PRODUCT_BUNDLE_IDENTIFIER; + const bundleId = raw ? resolveSetting(raw, vars) : null; + const ent = s.CODE_SIGN_ENTITLEMENTS ? resolveSetting(s.CODE_SIGN_ENTITLEMENTS, { ...vars, SRCROOT: '' }).replace(/^\/+/, '') : null; + out.push({ name, productType, kind, bundleId: bundleId && !/\$[({]/.test(bundleId) ? bundleId : null, ...(raw && (!bundleId || /\$[({]/.test(bundleId)) ? { unresolved: raw } : {}), entitlements: ent }); + } + return out; +} + +/** App groups (`com.apple.security.application-groups`) a profile's Entitlements allow. */ +export const profileAppGroups = (ent: Record | undefined): string[] => { + const g = ent?.['com.apple.security.application-groups']; + return Array.isArray(g) ? g.map(String) : []; +}; + +/** Groups the target's entitlements need that the profile does not carry. `*` in a profile allows any group. */ +export function appGroupGaps(want: string[], profileEnt: Record | undefined): string[] { + const have = profileAppGroups(profileEnt); + if (have.includes('*')) return []; + return want.filter(g => !have.includes(g)); +} + +/** The developer portal page of a bundle id (the id is the App Store Connect API resource id of the bundle id). */ +export const bundleIdPortalUrl = (resourceId: string): string => `https://developer.apple.com/account/resources/identifiers/bundleId/edit/${encodeURIComponent(resourceId)}`; + +export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null; /** why it is a gap / not proven (one line) */ why?: string } + +export const PORTAL_IDENTIFIERS_URL = 'https://developer.apple.com/account/resources/identifiers/list'; + +/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. + * The first line is the headline, the rest are the steps. */ +export function appGroupSteps(g: AppGroupGap): string[] { + const url = g.url ?? PORTAL_IDENTIFIERS_URL; + return [ + `${g.bundleId}: ${g.why ?? `the profile lacks App Group(s) ${g.groups.join(', ')}`} (the App Store Connect API can enable the App Groups capability but cannot assign a group to a bundle id).`, + `Open ${url} in the signed-in browser (agent-first: you drive it; plan: storebound console plan apple-app-group --bundle-id ${g.bundleId}${g.url ? '' : ' --lookup'}).`, + `Certificates, Identifiers & Profiles > Identifiers > ${g.bundleId} > App Groups (tick it if it is not ticked) > Configure > tick ${g.groups.join(', ')} (register a new group first under Identifiers > App Groups) > Continue > Save (and Confirm in the dialog that says the profiles get invalidated).`, + 'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button; change no other capability.', + 'Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.', + ]; +} + +/** Groups missing per bundle id -> one gap row each. `groupsOf` and `profileOf` are looked up by bundle id. */ +export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[] }[], profileEnt: (bundleId: string) => Record | undefined | null, resourceId: (bundleId: string) => string | null): AppGroupGap[] { + const out: AppGroupGap[] = []; + for (const t of targets) { + if (!t.appGroups.length) continue; + const ent = profileEnt(t.bundleId); + if (ent === null) continue; // no readable profile: unknown, not a gap + const groups = appGroupGaps(t.appGroups, ent); + if (groups.length) { const rid = resourceId(t.bundleId); out.push({ bundleId: t.bundleId, resourceId: rid, groups, url: rid ? bundleIdPortalUrl(rid) : null }); } + } + return out; +} + +/** What the API (or an installed profile) shows about one App ID's App Group association. */ +export interface GroupEvidence { + /** App Store Connect API resource id of the bundle id (the portal deep link); null: not registered / unknown */ + resourceId: string | null; + /** the APP_GROUPS capability is on the App ID (null: unknown) */ + capabilityOn: boolean | null; + /** the raw settings of the APP_GROUPS capability (any field that names a group proves it) */ + capabilitySettings?: unknown; + /** Entitlements of the newest readable profile of the bundle id (a managed one too); null: none readable */ + profileEnt: Record | null; +} + +/** Groups of `want` that the capability's settings name (the API has no documented App Group field; if a field ever + * lists them, this proves the association without a profile). */ +export const capabilityGroups = (settings: unknown, want: string[]): string[] => { + if (settings === null || settings === undefined) return []; + const text = JSON.stringify(settings); + return want.filter(g => text.includes(g)); +}; + +export interface GroupVerdicts { gaps: AppGroupGap[]; unproven: AppGroupGap[]; ok: string[] } + +/** + * Every target whose entitlements list App Groups: proven (a readable profile carries each group, or the capability + * settings name it), a gap (the evidence shows the group missing: a readable profile without it, or the capability + * off) or unproven (nothing readable: the API cannot show the association). Pure. + */ +export function appGroupVerdicts(targets: { bundleId: string; appGroups: string[] }[], evidence: (bundleId: string) => GroupEvidence | null): GroupVerdicts { + const out: GroupVerdicts = { gaps: [], unproven: [], ok: [] }; + for (const t of targets) { + if (!t.appGroups.length) continue; + const ev = evidence(t.bundleId); + const rid = ev?.resourceId ?? null, url = rid ? bundleIdPortalUrl(rid) : null; + const viaCap = new Set(capabilityGroups(ev?.capabilitySettings, t.appGroups)); + const missing = (ev?.profileEnt ? appGroupGaps(t.appGroups, ev.profileEnt) : t.appGroups).filter(g => !viaCap.has(g)); + if (!missing.length) { out.ok.push(t.bundleId); continue; } + if (ev?.capabilityOn === false) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the App Groups capability is not enabled on the App ID, so ${missing.join(', ')} is not associated` }); + else if (ev?.profileEnt) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the newest profile lacks App Group(s) ${missing.join(', ')} (an empty application-groups list means the group is not ticked on the App ID)` }); + else out.unproven.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: ev ? `App Group(s) ${missing.join(', ')} cannot be proven: the API shows no group on the App ID's capability and no readable profile` : `App Group(s) ${missing.join(', ')} cannot be proven: the App ID is not registered or could not be read` }); + } + return out; +} diff --git a/cli/lib/testflight.ts b/cli/lib/testflight.ts index 31381d7..b1bffc6 100644 --- a/cli/lib/testflight.ts +++ b/cli/lib/testflight.ts @@ -54,9 +54,14 @@ export const groupFromApi = (g: { id: string; attributes: Record [l.id, ...(l.apple ? [l.apple] : [])])); + const bad = Object.keys(t.description).filter(k => !known.has(k)); + if (bad.length) e.push(`apple.testflight.description: ${bad.map(k => `'${k}'`).join(', ')} is not a language of the config (use a language id or its App Store locale: ${langs.filter(l => l.apple).map(l => `${l.id} / ${l.apple}`).join(', ')})`); + } const names = new Set(); for (const [i, g] of (t.groups ?? []).entries()) { const at = `apple.testflight.groups[${i}]`; @@ -315,8 +320,28 @@ export function betaDescriptions(t: TfConfig | undefined, langs: { id: string; a const out: Record = {}; for (const l of langs) { if (!l.apple) continue; - const d = typeof t.description === 'string' ? t.description : t.description[l.id]; + // a language id ('en') or its App Store locale ('en-US'): an 'en-US' key was silently skipped (Acme 2026-10-29) + const d = typeof t.description === 'string' ? t.description : (t.description[l.id] ?? t.description[l.apple]); if (d) out[l.apple] = d; } return out; } + +// ---- invitations need an installable build ---- + +/** Whether a build can be installed by the testers of a group: valid, not expired, and past Beta App Review for an + * external group (externalBuildState BETA_APPROVED / IN_BETA_TESTING), ready for internal testing for an internal one + * (internalBuildState READY_FOR_BETA_TESTING / IN_BETA_TESTING). `detail`: the build's buildBetaDetails attributes. Pure. */ +export function installableBuild(build: { attributes: Record }, detail: Record | null | undefined, internal: boolean): boolean { + if (build.attributes.expired === true) return false; + if (build.attributes.processingState !== undefined && build.attributes.processingState !== 'VALID') return false; + const state = String((internal ? detail?.internalBuildState : detail?.externalBuildState) ?? ''); + return internal ? ['READY_FOR_BETA_TESTING', 'IN_BETA_TESTING'].includes(state) : ['BETA_APPROVED', 'IN_BETA_TESTING'].includes(state); +} + +/** The line for testers that are added but cannot be invited yet. Pure. */ +export const noInstallableBuildHint = (groups: string[]): string => + `tester added; the invite goes out when a build in ${groups.join(', ')} passes Beta App Review (re-run with --invite then)`; + +/** Apple's 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS on POST /v1/betaTesterInvitations (seen live). Pure. */ +export const noInstallableBuilds409 = (e: unknown): boolean => /NO_INSTALLABLE_BUILDS/.test(String((e as Error)?.message ?? e)); diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index 8470651..f7854e5 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -1,6 +1,7 @@ // Store readiness rules for `storebound validate`. Pure: the command collects a snapshot from both APIs (read-only), // these functions turn it into an ordered list of findings. Order = the order you would fix things in. +import { oldTrackBuilds } from '../play/old-builds'; import { consoleLink, consoleUrl } from './console-links'; import { describePending, type Pending, repairable } from '../apple/screenshot-health'; import { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions'; @@ -63,6 +64,8 @@ export interface AppleSnapshot { /** Screenshots of the version that are not COMPLETE (cli/apple/screenshot-health.ts): a submit answers 409 * ASSET_IN_POST_PROCESSING while any is left. */ screenshotsPending?: Pending[]; + /** Live version's device families vs the Xcode project and apple.ipad (cli/lib/device-families.ts familyVerdicts); undefined: not iOS. */ + deviceFamilies?: { level: 'ok' | 'blocker' | 'warn'; msg: string; fix?: string }[]; /** Editable or rejected versions of the app's other platforms (an app's macOS version has its own state). */ otherPlatformVersions?: { versionString: string; state: string; platform: string }[]; /** app.copyright from the config (undefined: not set). */ @@ -70,7 +73,10 @@ export interface AppleSnapshot { build: { number: string; processingState: string; usesNonExemptEncryption: boolean | null; declarationState: string | null } | null; /** Locales the config expects (apple: entries of `languages`). */ expectedLocales: string[]; - locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; screenshots: Record }[]; + locales: { locale: string; name?: string; subtitle?: string; description?: string; keywords?: string; promotionalText?: string; whatsNew?: string; screenshots: Record }[]; + /** apple.macos facts for the macOS claim check (validate fills it): the config's macOS review notes (what `apple review-details + * --platform macos` would send) and the gameCenter / noIap switches. */ + macos?: { notes?: string; gameCenter?: boolean; noIap?: boolean }; requiredDisplayTypes: string[]; /** apple.iphoneDuo (iPhone Duo renders on) and whether the project has a play section, for the Duo note. */ iphoneDuo?: boolean; @@ -161,6 +167,7 @@ export interface PlaySnapshot { const EDITABLE = ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY']; const IN_FLIGHT = ['WAITING_FOR_REVIEW', 'IN_REVIEW', 'PENDING_DEVELOPER_RELEASE', 'PROCESSING_FOR_DISTRIBUTION', 'READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'ACCEPTED']; const IAP_OK = ['READY_TO_SUBMIT', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'APPROVED']; +const IAP_OFF_SALE = ['DEVELOPER_REMOVED_FROM_SALE', 'REMOVED_FROM_SALE']; /** Age rating questions whose null means "not answered" (kidsAgeBand null = not a kids app). */ // Left null on apps App Review accepted: gracRatingClassificationNumber (a Korean GRAC rating number, for apps GRAC // rated), socialMediaAgeRestricted (asked only when socialMedia is true). socialMedia itself is a newer question that @@ -205,6 +212,35 @@ export function copyrightRules(s: Pick = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' }; +const MACOS_CLAIMS: { kind: string; re: RegExp; needs?: 'noGameCenter' | 'noIap'; what: string }[] = [ + { kind: 'game-center', re: /game\s*center/i, needs: 'noGameCenter', what: 'Game Center (set apple.macos.gameCenter: true when the Mac app has it)' }, + { kind: 'touch', re: /\b(tap|taps|tapping|swipe|swipes|swiping|pinch|pinches)\b|touch[\s-]?screen/i, what: 'touch words (tap, swipe, pinch, touch screen)' }, + { kind: 'ios-device', re: /\b(iphone|ipad)s?\b/i, what: 'an iPhone / iPad mention' }, + { kind: 'iap', re: /in[\s-]app[\s-]purchases?|\biaps?\b|restore purchases/i, needs: 'noIap', what: 'an in-app purchase line (apple.macos.noIap is set)' }, +]; + +/** + * A macOS version's texts (description, promotional text, what's new, keywords, subtitle) and review notes (live, and + * the config's apple.macos.reviewNotes / apple.info.reviewNotes) that carry iOS-only claims. Warnings only. Game Center + * is flagged unless apple.macos.gameCenter is true; in-app purchase lines only when apple.macos.noIap is set. Pure. + */ +export function macosClaimRules(s: Pick): Finding[] { + if (s.version?.platform !== 'MAC_OS') return []; + const texts: { where: string; text: string }[] = []; + for (const l of s.locales) for (const k of ['subtitle', 'description', 'promotionalText', 'whatsNew', 'keywords'] as const) if (l[k]) texts.push({ where: `${l.locale} ${k}`, text: l[k]! }); + if (s.reviewDetail?.notes) texts.push({ where: 'review notes (on the store)', text: s.reviewDetail.notes }); + if (s.macos?.notes) texts.push({ where: 'review notes (config)', text: s.macos.notes }); + const out: Finding[] = []; + for (const c of MACOS_CLAIMS) { + if (c.needs === 'noGameCenter' && s.macos?.gameCenter) continue; + if (c.needs === 'noIap' && !s.macos?.noIap) continue; + const hits = [...new Set(texts.filter(t => c.re.test(t.text)).map(t => t.where))]; + if (hits.length) out.push({ store: 'apple', level: 'warn', code: 'macos-claims', msg: `macOS version ${s.version.versionString}: ${c.what} in ${hits.slice(0, 6).join(', ')}${hits.length > 6 ? ` (+${hits.length - 6} more)` : ''}`, + fix: 'a separate macOS listing file (apple.macos.listing, then storebound apple listing --platform macos) and apple.macos.reviewNotes (storebound apple review-details --platform macos)' }); + } + return out; +} + export function appleRules(s: AppleSnapshot): Finding[] { const f: Finding[] = []; const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction, url?: string) => f.push({ store: 'apple', level, code, msg, fix, ...(action ? { action } : {}), ...(url ? { url } : {}) }); @@ -215,6 +251,7 @@ export function appleRules(s: AppleSnapshot): Finding[] { else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${vl}: ${s.version.state}`); else if (IN_FLIGHT.includes(s.version.state)) add('warn', 'version', `version ${vl} is ${s.version.state} (nothing to prepare)`); else add('warn', 'version', `version ${vl}: ${s.version.state}`); + for (const d of s.deviceFamilies ?? []) add(d.level, 'device-families', d.msg, d.fix); { const pend = s.screenshotsPending ?? []; const lines = describePending(pend); @@ -228,6 +265,7 @@ export function appleRules(s: AppleSnapshot): Finding[] { add('warn', 'version-platform', `version ${o.versionString} (${name}) is ${o.state} too: this report covers ${vl || 'iOS'} only`, `storebound validate --platform ${name.toLowerCase()}; storebound apple status lists every platform`); } f.push(...copyrightRules(s)); + f.push(...macosClaimRules(s)); if (!s.build) add('blocker', 'build', 'no build attached to the version', 'storebound apple listing --version x.y.z --build N (pick the build number you just uploaded)'); else { @@ -278,12 +316,17 @@ export function appleRules(s: AppleSnapshot): Finding[] { const iaps = new Map(s.iaps.map(i => [i.productId, i] as const)); for (const id of s.expectedProducts) if (!iaps.has(id)) add('blocker', 'iap', `${id}: not on App Store Connect`, 'storebound apple iap'); + const expected = new Set(s.expectedProducts); for (const i of s.iaps) { + // a product the developer took off sale (old consumables, a retired unlock) does not block a submission; it matters + // only when the config still sells it + if (IAP_OFF_SALE.includes(i.state)) { if (expected.has(i.productId)) add('warn', 'iap', `${i.productId}: ${i.state}, but the config still sells it`, 'put it back on sale in App Store Connect, or drop it from products'); continue; } if (!IAP_OK.includes(i.state)) add('blocker', 'iap', `${i.productId}: ${i.state}`, i.state === 'MISSING_METADATA' ? 'storebound apple iap + apple iap-review' : 'see the review-rejections skill'); else if (!i.reviewScreenshot && i.state !== 'APPROVED') add('blocker', 'iap', `${i.productId}: no review screenshot`, 'storebound apple iap-review'); } for (const g of s.firstProductGaps ?? []) add(g.level === 'error' ? 'blocker' : 'warn', 'iap-first', g.msg, g.fix); - if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`); + const onSale = s.iaps.filter(i => !IAP_OFF_SALE.includes(i.state)).length, offSale = s.iaps.length - onSale; + if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${onSale} in-app purchases ready${offSale ? ` (${offSale} removed from sale, not checked)` : ''}`); f.push(...appleSubscriptionRules(s)); for (const m of s.capabilityMismatches ?? []) add('warn', 'capabilities', m.msg, m.fix); @@ -367,6 +410,7 @@ export function playRules(s: PlaySnapshot): Finding[] { if (r.status === 'halted') add('warn', 'tracks', `${t.track}: release ${r.versionCodes.join(',')} is halted`, 'storebound play rollout --resume (owner)'); if (r.status === 'inProgress') add('ok', 'tracks', `${t.track}: rollout ${Math.round((r.userFraction ?? 0) * 100)}% of ${r.versionCodes.join(',')}`); } + for (const o of oldTrackBuilds(s.tracks)) add('warn', 'old-builds', `${o.track}: old build ${o.versionCodes.join(',')} (${o.status}) is still active below production ${o.productionCode}; Play reviews it with every submission and a policy issue in it (e.g. READ_MEDIA_IMAGES / READ_MEDIA_VIDEO) blocks Send changes for review (pitfall 129)`, `storebound play release --version-code ${o.productionCode} ${['internal','alpha','beta'].includes(o.track) ? '--' + o.track : '--track ' + o.track} completed`); if (anyRelease && !f.some(x => x.code === 'tracks')) add('ok', 'tracks', s.tracks.filter(t => t.releases.length).map(t => `${t.track}: ${t.releases.map(r => r.status).join('/')}`).join(', ')); const prods = new Map(s.products.map(p => [p.productId, p] as const)); diff --git a/cli/lib/walk-skip.ts b/cli/lib/walk-skip.ts new file mode 100644 index 0000000..2015fb9 --- /dev/null +++ b/cli/lib/walk-skip.ts @@ -0,0 +1,42 @@ +// Which folders a project walk (scan, check's privacy manifests) leaves out, beyond each walker's own name list: +// - dot folders below the root (.harness-worktrees, .wt-head, .ota-out, .native-build, ...): tool state, caches and +// whole copies of the repo, never app sources; +// - linked git worktrees (another checkout of the same repo); +// - folders git ignores at the root (release-out/, artifacts/, ...), asked once per root with +// `git ls-files --others --ignored --exclude-standard --directory`; SDK folders the privacy checks read (Pods, +// Carthage, SourcePackages) and generated native projects (ios, android) stay in even when ignored. +// Seen live: a game repo with several worktrees and release folders made `check` walk ~60 000 files for 2.5 minutes. +import { readFileSync } from 'node:fs'; +import { join, relative, resolve, sep } from 'node:path'; +import * as rt from './rt'; + +// kept even when git ignores them: SDK folders the privacy checks read, and generated native projects (Expo prebuild) +const KEEP = new Set(['Pods', 'Carthage', 'SourcePackages', 'ios', 'android', 'macos']); +const cache = new Map>(); + +/** A linked git worktree: its .git is a file pointing into /.git/worktrees/ (a submodule's points into modules/). */ +export function isWorktree(dir: string): boolean { + try { return /^gitdir:.*[\\/]worktrees[\\/]/m.test(readFileSync(join(dir, '.git'), 'utf8')); } catch { return false; } +} + +/** Root-relative folders (with "/" separators, no trailing slash) git ignores in this repo; empty outside git. */ +export function gitIgnoredDirs(root: string): Set { + const key = resolve(root); + const hit = cache.get(key); + if (hit) return hit; + const out = new Set(); + try { + const r = rt.spawnSync(['git', 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: key, timeout: 15_000 }); + if (r.success) for (const p of r.stdout.toString('utf8').split('\0')) if (p.endsWith('/')) out.add(p.slice(0, -1)); + } catch { /* no git */ } + cache.set(key, out); + return out; +} + +/** Leave this folder out of a walk that started at root? */ +export function skipDir(root: string, abs: string, name: string): boolean { + if (KEEP.has(name)) return false; + if (name.startsWith('.') && resolve(abs) !== resolve(root)) return true; + if (gitIgnoredDirs(root).has(relative(root, abs).split(sep).join('/'))) return true; + return isWorktree(abs); +} diff --git a/cli/lib/web-assets.ts b/cli/lib/web-assets.ts index 638b669..83d0179 100644 --- a/cli/lib/web-assets.ts +++ b/cli/lib/web-assets.ts @@ -14,7 +14,7 @@ // The comparison (webStaleness) and the capacitor config reading (webDirOf) are pure. import { createHash } from 'node:crypto'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { dirname, join, relative, sep } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; import type { ShipConfig } from './config'; import { log, warn } from './log'; import { pump } from './runner'; @@ -89,7 +89,7 @@ export function capacitorCandidates(root: string): string[] { */ export function capacitorProject(root: string, hint?: string): CapProject | null { if (hint) { - for (let d = join(root, hint); ; d = dirname(d)) { + for (let d = resolve(root, hint); ; d = dirname(d)) { const rel = relative(root, d); if (rel.startsWith('..')) break; const c = capAt(root, rel.split(sep).join('/')); diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index fe0dd52..ed5f4a7 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -1,7 +1,7 @@ // Xcode builds without Xcode's UI: find the workspace / project and scheme (native, Capacitor, React Native, Flutter, // Unity-exported projects), build the xcodebuild archive and export command lines, read an IPA's Info.plist. // Pure except scanXcode (a bounded directory walk) and ipaInfo (reads the IPA). -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { basename, dirname, join, relative } from 'node:path'; import { parsePlist, type PlistValue } from './plist'; import { zipEntries, zipRead } from './zip'; @@ -18,6 +18,10 @@ export interface AppleBuildConfig { configuration?: string; /** Output folder for the archive, ExportOptions.plist, the IPA and logs. Default build/ios. */ out?: string; + /** xcodebuild -derivedDataPath. Default /DerivedData when the output folder is chosen (--out / out), else Xcode's own folder under ~/Library. */ + derivedData?: string; + /** Remap the home folder, checkout, output folder and DerivedData out of the binary and the debug symbols, then scan the archive (same as --path-free). */ + pathFree?: boolean; } export function validateAppleBuild(b: AppleBuildConfig | undefined): string[] { @@ -26,6 +30,8 @@ export function validateAppleBuild(b: AppleBuildConfig | undefined): string[] { if (b.workspace && !b.workspace.endsWith('.xcworkspace')) e.push('apple.build.workspace: a path ending in .xcworkspace'); if (b.project && !b.project.endsWith('.xcodeproj')) e.push('apple.build.project: a path ending in .xcodeproj'); if (b.scheme !== undefined && !b.scheme.trim()) e.push('apple.build.scheme: empty'); + if (b.derivedData !== undefined && (typeof b.derivedData !== 'string' || !b.derivedData.trim())) e.push('apple.build.derivedData: a folder path'); + if (b.pathFree !== undefined && typeof b.pathFree !== 'boolean') e.push('apple.build.pathFree: true or false'); return e; } @@ -125,14 +131,14 @@ export interface AscAuth { keyPath: string; keyId: string; issuerId: string } const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []); -export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null } +export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain */ settings?: string[]; /** xcodebuild -derivedDataPath */ derivedData?: string | null } /** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */ export function archiveArgs(i: ArchiveInput): string[] { return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]), '-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath, - ...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []), - ...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : [])]; + ...(i.derivedData ? ['-derivedDataPath', i.derivedData] : []), ...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []), + ...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])]; } export function exportArgs(archivePath: string, plist: string, exportPath: string, auth: AscAuth | null): string[] { @@ -260,3 +266,21 @@ export function remoteBuildSession(env: Record, mana if (managerName && managerName !== 'Aqua') return `this is not the logged-in GUI session (launchctl manager: ${managerName})`; return null; } + +/** Where `apple build` notes its last IPA for `apple upload`: .storebound/last-apple-build.json (gitignored). */ +export const lastBuildFile = (root: string): string => join(root, '.storebound', 'last-apple-build.json'); + +export function recordLastBuild(root: string, r: { ipa: string; out: string; version: string; build: string }): void { + try { mkdirSync(join(root, '.storebound'), { recursive: true }); writeFileSync(lastBuildFile(root), `${JSON.stringify(r, null, 2)}\n`); } catch { /* best effort */ } +} + +export function readLastBuild(root: string): { ipa: string; out: string; version?: string; build?: string } | null { + try { const j = JSON.parse(readFileSync(lastBuildFile(root), 'utf8')); return typeof j?.ipa === 'string' && typeof j?.out === 'string' ? j : null; } catch { return null; } +} + +/** The export folders `apple upload` searches, in order: --out, else the last build's out (a --out that build used), + * then apple.build.out / build/ios. Duplicates dropped. */ +export function uploadSearchDirs(o: { flagOut?: string; lastOut?: string; configOut: string }): string[] { + const dirs = o.flagOut ? [o.flagOut] : [...(o.lastOut ? [o.lastOut] : []), o.configOut]; + return [...new Set(dirs.map(d => join(d, 'export')))]; +} diff --git a/cli/mcp-cmd.ts b/cli/mcp-cmd.ts new file mode 100644 index 0000000..24dde9d --- /dev/null +++ b/cli/mcp-cmd.ts @@ -0,0 +1,15 @@ +// `storebound mcp`: stdio by default (one process per agent session), or `--http` for one shared server +// (cli/mcp-http.ts: every session of the machine, or a team behind OAuth). +import type { parseArgs } from './lib/args'; + +export async function run(a: ReturnType): Promise { + if (!a.has('http')) return (await import('./mcp')).serve(); + const { serveHttp } = await import('./mcp-http'); + await serveHttp({ + host: a.get('host'), port: a.get('port'), roots: a.get('roots'), project: a.get('project'), allowOrigin: a.get('allow-origin'), + auth: a.get('auth'), tokenFile: a.get('token-file'), introspectUrl: a.get('introspect-url'), introspectKeyFile: a.get('introspect-key-file'), + resource: a.get('resource'), issuer: a.get('issuer'), allowRemoteToken: a.has('allow-remote-token'), + brokerProfiles: a.get('broker-profiles'), readOnly: a.has('read-only'), + public: a.has('public'), rate: a.get('rate'), trustProxy: a.has('trust-proxy'), + }); +} diff --git a/cli/mcp-http.ts b/cli/mcp-http.ts new file mode 100644 index 0000000..8b24a3f --- /dev/null +++ b/cli/mcp-http.ts @@ -0,0 +1,306 @@ +// `storebound mcp --http`: the same MCP server over Streamable HTTP, so one process serves every agent session on a +// machine (or one host serves a whole team) instead of one stdio process per session. +// storebound mcp --http [--host 127.0.0.1] [--port 7787] [--roots ] [--project ] +// [--auth token|introspect] [--token-file ~/.storebound/mcp-token] +// storebound mcp --http --public [--port 7788] [--host 127.0.0.1] [--rate 60] [--trust-proxy] (see below) +// [--introspect-url --introspect-key-file --resource --issuer ] +// Transport (https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http): one endpoint, +// /mcp. POST carries one JSON-RPC message and gets one JSON answer (202 for a notification); GET and DELETE are 405 +// (no server-initiated stream, no session state: every call is independent, so no Mcp-Session-Id is issued). The +// Origin header, when sent, must be a localhost origin unless --allow-origin names it (DNS rebinding). +// Project: every tool gets a `project` argument, the absolute path of the app folder the CLI runs in. It must be inside +// --roots (default: the home folder); --project (or STOREBOUND_PROJECT) is the default when a call has none. +// Public (--public): the key-less server behind a public URL. No sign-in, no project, no store credentials; it serves only the +// read-only tools of mcp-public.ts (skills, pitfalls, limits, listing lint, App Store search), never starts a process, rate +// limited per client (--rate per minute, 429 + Retry-After; --trust-proxy reads the first X-Forwarded-For entry), body <= 64 KB. +// Auth (every request to /mcp): +// token (default): `Authorization: Bearer `, the token in --token-file (made with mode 600 on first start; +// never printed). Clients: claude mcp add --transport http storebound http://127.0.0.1:7787/mcp +// --header "Authorization: Bearer $(cat ~/.storebound/mcp-token)". +// introspect: an OAuth 2.0 bearer token checked at --introspect-url (RFC 7662; this server authenticates with the +// key in --introspect-key-file). The token must be active, carry the `mcp` scope and name --resource in aud +// (RFC 8707). Without a valid token: 401 with WWW-Authenticate: Bearer resource_metadata=".../.well-known/ +// oauth-protected-resource/mcp" (RFC 9728), which names --issuer as the authorization server. Answers are cached +// for 60 s per token (hashed); the token itself is never logged. +import { createHash, randomBytes } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import pkg from '../package.json' with { type: 'json' }; +import { handle, LEGACY, PROTOCOL } from './mcp'; +import { clientKey, createLimiter, publicSurface, type PublicDeps } from './mcp-public'; +import { TOOLS } from './mcp-tools'; +import { brokerConfig, brokerProfileEnv } from './lib/broker'; +import { runCommand } from './lib/runner'; + +export const DEFAULT_PORT = 7787; +export const PUBLIC_PORT = 7788; +const MAX_BODY = 4 * 1024 * 1024; +/** SSE comment line interval while a tool call runs (proxies cut silent responses). */ +export let SSE_KEEPALIVE_MS = 20_000; +export const setKeepaliveMs = (ms: number): void => { SSE_KEEPALIVE_MS = ms; }; +/** Public mode: one JSON-RPC message of a read-only tool never needs more. */ +export const PUBLIC_MAX_BODY = 64 * 1024; +export const PUBLIC_DEFAULT_RATE = 60; + +export interface HttpOptions { + host: string; + port: number; + roots: string[]; + project: string | null; + allowOrigins: string[]; + /** Credential profiles to take from the broker vault for every call (no key files on this host). */ + brokerProfiles?: string[]; + /** Only the tools that change nothing (no dry_run tools at all). */ + readOnly?: boolean; + /** Public key-less mode (`--public`): auth kind 'none', the tool surface of mcp-public.ts, a rate limit per client. */ + public?: { rate: number; trustProxy: boolean } & PublicDeps; + auth: { kind: 'none' } | { kind: 'token'; token: string } | { kind: 'introspect'; url: string; key: string; resource: string; issuer: string }; +} + +export const PROJECT_PROP = { type: 'string', description: 'The app folder (the one with storebound.config.ts) to run in: an absolute path, or a folder name under the server roots. Required unless the server was started with --project.' } as const; + +/** Inside one of the roots (path compare, case-insensitive on Windows); no "..", no other drive. */ +export function insideRoots(dir: string, roots: string[]): boolean { + const norm = (p: string) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p)); + return roots.some(r => { const rel = relative(norm(r), norm(dir)); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); }); +} + +/** The folder a call runs in, or the reason it cannot run. */ +export function projectFor(arg: unknown, o: Pick): { dir: string } | { error: string } { + if (arg === undefined || arg === null || arg === '') return o.project ? { dir: o.project } : { error: 'project: required (absolute path of the app folder)' }; + // a plain folder name is a project under one of the roots (a shared server keeps its checkouts there) + if (typeof arg === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(arg)) { + const hit = o.roots.map(r => join(r, arg)).find(d => { try { return statSync(d).isDirectory(); } catch { return false; } }); + return hit ? { dir: resolve(hit) } : { error: `project: no folder ${arg} under ${o.roots.join(', ')}` }; + } + if (typeof arg !== 'string' || !isAbsolute(arg)) return { error: 'project: must be an absolute path, or a folder name under the roots' }; + if (!insideRoots(arg, o.roots)) return { error: `project: ${arg} is outside the server's roots (${o.roots.join(', ')})` }; + try { if (!statSync(arg).isDirectory()) return { error: `project: ${arg} is not a folder` }; } catch { return { error: `project: ${arg} does not exist` }; } + return { dir: resolve(arg) }; +} + +/** Localhost origins are always fine; others only when listed. No Origin header (non-browser clients) is fine. */ +export function originAllowed(origin: string | undefined, allow: string[]): boolean { + if (!origin) return true; + if (allow.includes(origin)) return true; + try { const u = new URL(origin); return ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname); } catch { return false; } +} + +/** The protected resource metadata URL for a resource URL (RFC 9728 section 3: the path goes after the well-known part). */ +export function metadataUrl(resource: string): string { + const u = new URL(resource); + return `${u.origin}/.well-known/oauth-protected-resource${u.pathname === '/' ? '' : u.pathname}`; +} + +/** Introspection answer -> allowed? (active, `mcp` scope, our resource in aud). */ +export function introspectionOk(j: unknown, resource: string): boolean { + if (!j || typeof j !== 'object') return false; + const r = j as { active?: unknown; scope?: unknown; aud?: unknown }; + if (r.active !== true) return false; + const scopes = typeof r.scope === 'string' ? r.scope.split(/\s+/) : []; + if (!scopes.includes('mcp')) return false; + const aud = Array.isArray(r.aud) ? r.aud : typeof r.aud === 'string' ? [r.aud] : []; + return aud.includes(resource); +} + +const bearer = (req: IncomingMessage): string | null => { const h = req.headers.authorization ?? ''; const m = /^Bearer\s+(\S+)$/i.exec(h); return m ? m[1]! : null; }; + +const cache = new Map(); +async function authorized(req: IncomingMessage, o: HttpOptions): Promise { + const t = bearer(req); + if (!t || o.auth.kind === 'none') return false; + if (o.auth.kind === 'token') { + const a = createHash('sha256').update(t).digest(), b = createHash('sha256').update(o.auth.token).digest(); + return a.equals(b); + } + const key = createHash('sha256').update(t).digest('hex'); + const hit = cache.get(key); + if (hit && hit.until > Date.now()) return hit.ok; + let ok = false; + try { + const r = await fetch(o.auth.url, { method: 'POST', signal: AbortSignal.timeout(10_000), + headers: { authorization: `Bearer ${o.auth.key}`, 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: new URLSearchParams({ token: t }).toString() }); + ok = r.ok && introspectionOk(await r.json(), o.auth.resource); + } catch { return false; } // no cache on a network error + cache.set(key, { ok, until: Date.now() + 60_000 }); + return ok; +} + +function sendRaw(res: ServerResponse, status: number, body?: unknown, headers: Record = {}): void { + res.writeHead(status, { 'cache-control': 'no-store', ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers }); + res.end(body === undefined ? undefined : JSON.stringify(body)); +} + +async function readBody(req: IncomingMessage, max = MAX_BODY): Promise { + let size = 0; const parts: Buffer[] = []; + for await (const c of req) { size += (c as Buffer).length; if (size > max) return null; parts.push(c as Buffer); } + return Buffer.concat(parts).toString('utf8'); +} + +/** One POST body -> the answer (null for a notification). tools/list gains `project`; tools/call runs in it. */ +export async function answer(msg: Record, o: Pick & { readOnly?: boolean }, + run: (argv: string[], cwd: string) => Promise<{ code: number; out: string }> = runIn): Promise | null> { + const params = (msg.params ?? {}) as Record; + if (msg.method === 'tools/call') { + if (o.readOnly && TOOLS.find(t => t.name === params.name)?.write) return { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32602, message: `Unknown tool: ${String(params.name)} (this server is read-only)` } }; + const args = { ...((params.arguments ?? {}) as Record) }; + const p = projectFor(args.project, o); + if ('error' in p) return { jsonrpc: '2.0', id: msg.id ?? null, result: { content: [{ type: 'text', text: `Invalid arguments: ${p.error}` }], isError: true } }; + delete args.project; + return handle({ ...msg, params: { ...params, arguments: args } } as never, argv => run(argv, p.dir)); + } + const r = await handle(msg as never); + if (msg.method === 'tools/list' && r && typeof r.result === 'object') { + const res = r.result as { tools?: { name: string; annotations?: { readOnlyHint?: boolean }; inputSchema: { properties: Record; required?: string[] } }[] }; + if (o.readOnly) res.tools = (res.tools ?? []).filter(t => t.annotations?.readOnlyHint === true); + const tools = res.tools ?? []; + for (const t of tools) { + t.inputSchema.properties = { project: PROJECT_PROP, ...t.inputSchema.properties }; + if (!o.project) t.inputSchema.required = ['project', ...(t.inputSchema.required ?? [])]; + } + } + return r; +} + +/** Public mode never runs the CLI: a call that would is a bug, not a request. */ +const noRun = async (): Promise<{ code: number; out: string }> => { throw new Error('public mode does not run commands'); }; + +let brokerEnv: { env: Promise>; until: number } | null = null; +/** The broker profiles' credentials as child env, fetched at most every 10 minutes (memory only, never logged). */ +async function profileEnvFor(profiles: string[] | undefined): Promise> { + if (!profiles?.length) return {}; + if (!brokerEnv || brokerEnv.until < Date.now()) { + const cfg = brokerConfig(); + if (!cfg) throw new Error('--broker-profiles needs a credential broker (~/.storebound/broker.json or STOREBOUND_BROKER_URL)'); + const env = brokerProfileEnv(cfg, profiles); + brokerEnv = { env, until: Date.now() + 600_000 }; + env.catch(() => { brokerEnv = null; }); + } + return brokerEnv.env; +} +let serverProfiles: string[] | undefined; + +async function runIn(argv: string[], cwd: string): Promise<{ code: number; out: string }> { + const r = runCommand(argv, { cwd, env: { STOREBOUND_MCP: '1', ...(await profileEnvFor(serverProfiles)) } }); + const { code, out } = await r.result; + return { code, out }; +} + +export function startHttp(o: HttpOptions): Promise<{ close: () => void; port: number }> { + serverProfiles = o.brokerProfiles; + if (o.auth.kind === 'none' && !o.public) throw new Error('auth none is only for --public'); + const pub = o.public ? { limiter: createLimiter(o.public.rate), surface: publicSurface(o.public), trustProxy: o.public.trustProxy } : null; + // no ambient credential in public mode, so any browser origin may call it + const cors: Record = pub ? { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, accept, mcp-protocol-version', 'access-control-max-age': '86400' } : {}; + const send = (res: ServerResponse, status: number, body?: unknown, headers: Record = {}) => sendRaw(res, status, body, { ...cors, ...headers }); + const resourceMeta = o.auth.kind === 'introspect' ? metadataUrl(o.auth.resource) : null; + const server = createServer(async (req, res) => { + const path = (req.url ?? '/').split('?')[0]; + if (resourceMeta && o.auth.kind === 'introspect' && req.method === 'GET' && path === new URL(resourceMeta).pathname) + return send(res, 200, { resource: o.auth.resource, authorization_servers: [o.auth.issuer], bearer_methods_supported: ['header'], scopes_supported: ['mcp'], resource_name: 'storebound' }); + if (path === '/health' && req.method === 'GET') return send(res, 200, { ok: true, version: pkg.version }); + // public: the bare host is the MCP endpoint too (people paste "mcp.example.com" into a connector), and a browser + // opening it gets the connect line instead of a 404 + if (pub && path === '/' && req.method === 'GET') { + // a person in a browser goes to the landing page; any other client gets the connect line + if (/text\/html/.test(String(req.headers.accept ?? ''))) { res.writeHead(302, { ...cors, location: 'https://storebound.dev/' }); return res.end(); } + res.writeHead(200, { ...cors, 'content-type': 'text/plain; charset=utf-8' }); + return res.end(`storebound MCP server ${pkg.version} (read-only, no key)\nAdd it as a remote MCP server / custom connector with this URL. Claude Code: claude mcp add --transport http storebound \n`); + } + if (path !== '/mcp' && !(pub && path === '/')) return send(res, 404, { error: 'not found' }); + if (pub) { + if (req.method === 'OPTIONS') return send(res, 204); + const hit = pub.limiter.hit(clientKey(req.headers['x-forwarded-for'], req.socket.remoteAddress, pub.trustProxy)); + if (!hit.ok) return send(res, 429, { error: 'rate limit exceeded' }, { 'retry-after': String(hit.retryAfter) }); + } else if (!originAllowed(req.headers.origin, o.allowOrigins)) return send(res, 403, { error: 'origin not allowed' }); + if (!pub && !(await authorized(req, o))) + return send(res, 401, { error: 'unauthorized' }, { 'www-authenticate': resourceMeta ? `Bearer resource_metadata="${resourceMeta}"` : 'Bearer' }); + if (req.method !== 'POST') return send(res, 405, { error: 'POST only (no server-initiated stream)' }, { allow: 'POST' }); + const body = await readBody(req, pub ? PUBLIC_MAX_BODY : MAX_BODY); + if (body === null) return send(res, 413, { error: 'body too large' }); + let msg: unknown; + try { msg = JSON.parse(body); } catch { return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); } + if (Array.isArray(msg) || typeof msg !== 'object' || msg === null) return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'One JSON-RPC message per request' } }); + const work = (async () => { + try { return pub ? await handle(msg as never, noRun, pub.surface) : await answer(msg as Record, o); } + catch (e) { return { jsonrpc: '2.0', id: (msg as { id?: unknown }).id ?? null, error: { code: -32603, message: String((e as Error).message) } }; } + })(); + // A tool call may run for minutes; a proxy (Cloudflare: 100 s) cuts a response that sends nothing. A client that + // accepts SSE gets the answer as one SSE event with a comment line every 20 s until then. + if ((msg as { method?: unknown }).method === 'tools/call' && /text\/event-stream/.test(String(req.headers.accept ?? ''))) { + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', 'x-accel-buffering': 'no' }); + res.write(': started\n\n'); + const beat = setInterval(() => res.write(': keepalive\n\n'), SSE_KEEPALIVE_MS); + const r = await work.finally(() => clearInterval(beat)); + res.end(`event: message\ndata: ${JSON.stringify(r)}\n\n`); + return; + } + const r = await work; + return r ? send(res, 200, r) : send(res, 202); + }); + // a tool call can run for many minutes (check on a big project, uploads): Node's 300 s request timeout would cut it + server.requestTimeout = 0; + server.timeout = 0; + return new Promise((ok, fail) => { + server.once('error', fail); + server.listen(o.port, o.host, () => { + const a = server.address(); const port = typeof a === 'object' && a ? a.port : o.port; + ok({ close: () => server.close(), port }); + }); + }); +} + +/** The local bearer token: read, or made (32 random bytes, hex, mode 600) on first start. */ +export function tokenFrom(file: string): string { + if (existsSync(file)) { const t = readFileSync(file, 'utf8').trim(); if (t.length >= 32) return t; } + mkdirSync(dirname(file), { recursive: true }); + const t = randomBytes(32).toString('hex'); + writeFileSync(file, `${t}\n`, { mode: 0o600 }); + try { chmodSync(file, 0o600); } catch { /* Windows */ } + return t; +} + +const home = (p: string) => (p.startsWith('~') ? join(homedir(), p.slice(1)) : p); + +export interface HttpFlags { host?: string; port?: string; roots?: string; project?: string; allowOrigin?: string; auth?: string; tokenFile?: string; + introspectUrl?: string; introspectKeyFile?: string; resource?: string; issuer?: string; allowRemoteToken?: boolean; + brokerProfiles?: string; readOnly?: boolean; public?: boolean; rate?: string; trustProxy?: boolean } + +export async function serveHttp(f: HttpFlags): Promise { + const host = f.host ?? '127.0.0.1'; + if (f.public) { + const bad = (['auth', 'tokenFile', 'roots', 'project', 'brokerProfiles', 'allowOrigin', 'introspectUrl'] as const).filter(k => f[k] !== undefined); + if (bad.length || f.readOnly) throw new Error('--public takes no credentials or project: do not combine it with --auth, --token-file, --roots, --project, --broker-profiles, --allow-origin, --introspect-* or --read-only'); + const rate = Number(f.rate ?? PUBLIC_DEFAULT_RATE); + if (!Number.isInteger(rate) || rate < 1) throw new Error('--rate: requests per minute per client, a whole number >= 1'); + const port = Number(f.port ?? PUBLIC_PORT); + const s = await startHttp({ host, port, roots: [], project: null, allowOrigins: [], auth: { kind: 'none' }, public: { rate, trustProxy: !!f.trustProxy } }); + console.error(`[storebound mcp] ${pkg.version} PUBLIC key-less server on http://${host}:${s.port}/mcp (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), read-only, ${rate} requests/min per client${f.trustProxy ? ', X-Forwarded-For trusted' : ''}`); + return; + } + const port = Number(f.port ?? DEFAULT_PORT); + const roots = (f.roots ?? homedir()).split(',').map(s => resolve(home(s.trim()))).filter(Boolean); + const projectArg = f.project ?? process.env.STOREBOUND_PROJECT; + const project = projectArg ? resolve(home(projectArg)) : null; + const allowOrigins = (f.allowOrigin ?? '').split(',').map(s => s.trim()).filter(Boolean); + const kind = f.auth ?? 'token'; + let auth: HttpOptions['auth']; + if (kind === 'introspect') { + const { introspectUrl: url, introspectKeyFile: keyFile, resource, issuer } = f; + if (!url || !keyFile || !resource || !issuer) throw new Error('--auth introspect needs --introspect-url, --introspect-key-file, --resource and --issuer'); + auth = { kind, url, key: readFileSync(home(keyFile), 'utf8').trim(), resource, issuer }; + } else if (kind === 'token') { + const file = home(f.tokenFile ?? '~/.storebound/mcp-token'); + auth = { kind, token: tokenFrom(file) }; + console.error(`[storebound mcp] bearer token in ${file}`); + } else throw new Error('--auth token | introspect'); + if (!['127.0.0.1', 'localhost', '::1'].includes(host) && kind === 'token' && !f.allowRemoteToken) + throw new Error(`--host ${host} listens beyond this machine: use --auth introspect (or --allow-remote-token behind your own TLS proxy)`); + const brokerProfiles = (f.brokerProfiles ?? '').split(',').map(s => s.trim()).filter(Boolean); + if (brokerProfiles.length) await profileEnvFor(brokerProfiles).catch(e => { throw new Error(`--broker-profiles: ${(e as Error).message}`); }); + const s = await startHttp({ host, port, roots, project, allowOrigins, auth, brokerProfiles, readOnly: !!f.readOnly }); + console.error(`[storebound mcp] ${pkg.version} on http://${host}:${s.port}/mcp (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), auth ${kind}${f.readOnly ? ', read-only' : ''}${brokerProfiles.length ? `, broker profiles ${brokerProfiles.join(', ')}` : ''}, roots ${roots.join(', ')}${project ? `, default project ${project}` : ''}`); +} diff --git a/cli/mcp-public.ts b/cli/mcp-public.ts new file mode 100644 index 0000000..75853ba --- /dev/null +++ b/cli/mcp-public.ts @@ -0,0 +1,329 @@ +// The public, key-less MCP surface (`storebound mcp --http --public`): what anyone may call without signing in. +// Everything is pure and in-process. This module never reads store credentials, project folders or the cwd, never +// starts a child process (no runner, no CLI) and makes one kind of network call: the public iTunes Search API +// (aso_search). The only files it reads are the skills/ folder of the storebound package itself. +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Surface } from './mcp'; +import { APPLE_LIMITS, PLAY_LIMITS } from './lib/copy'; +import { parseFrontMatter } from './lib/frontmatter'; +import { appleLocaleIssues, playLangIssues } from './lib/listing-rules'; +import { IMAGE_SPECS, PREVIEW_RULES, VIDEO_TARGETS } from './shots/spec'; + +export const PUBLIC_INSTRUCTIONS = [ + 'This is the public, key-less storebound server: anyone may use it, it needs no sign-in and it never touches store accounts, keys or project files.', + 'It is read-only and offers store knowledge: the skills (skills_list, skill_get), known store pitfalls (pitfalls_search), listing and screenshot limits (store_limits), a text linter for App Store / Google Play listings (listing_lint) and a public App Store search (aso_search).', + 'For real store work (builds, uploads, listings, releases) install the toolchain: `npx storebound` (see https://storebound.dev) and use its own MCP server, `npx storebound mcp`.', +].join(' '); + +export interface PublicDeps { + /** The folder with /SKILL.md files. Default: skills/ of the storebound package that contains this file. */ + skillsDir?: string; + /** iTunes Search API endpoint (tests point it at a local fake). */ + searchBase?: string; + fetch?: typeof fetch; +} + +export interface PublicResult { text: string; data?: unknown; isError?: boolean } +export interface PublicTool { + name: string; title: string; description: string; + inputSchema: { type: 'object'; properties: Record; required?: string[]; additionalProperties: false }; + network?: boolean; + run(input: Record): Promise | PublicResult; +} +export interface Schema { type: string; description?: string; enum?: string[]; pattern?: string; minLength?: number; maxLength?: number; minimum?: number; maximum?: number; + properties?: Record; additionalProperties?: boolean } + +export const SEARCH_BASE = 'https://itunes.apple.com/search'; +const MAX_TEXT = 40_000; +const SKILL_NAME = /^[a-z0-9-]+$/; + +// ---- input validation (JSON-Schema subset these tools use) ---- + +export function validatePublic(schema: Schema, input: unknown, path = ''): string[] { + const e: string[] = []; + if (input === undefined || input === null) input = {}; + if (typeof input !== 'object' || Array.isArray(input)) return [`${path || 'arguments'}: must be an object`]; + const i = input as Record; + const props = schema.properties ?? {}; + for (const r of (schema as { required?: string[] }).required ?? []) if (i[r] === undefined) e.push(`${path}${r}: required`); + for (const [k, v] of Object.entries(i)) { + const p = props[k]; + if (!p) { e.push(`${path}${k}: unknown argument`); continue; } + if (v === undefined) continue; + if (p.type === 'object') { e.push(...validatePublic(p, v, `${path}${k}.`)); continue; } + if (p.type === 'string') { + if (typeof v !== 'string') { e.push(`${path}${k}: must be a string`); continue; } + if (p.enum && !p.enum.includes(v)) e.push(`${path}${k}: one of ${p.enum.join(', ')}`); + if (p.pattern && !new RegExp(p.pattern).test(v)) e.push(`${path}${k}: does not match ${p.pattern}`); + if (p.minLength !== undefined && v.length < p.minLength) e.push(`${path}${k}: at least ${p.minLength} characters`); + if (p.maxLength !== undefined && v.length > p.maxLength) e.push(`${path}${k}: at most ${p.maxLength} characters`); + } else if (p.type === 'integer') { + if (typeof v !== 'number' || !Number.isInteger(v)) { e.push(`${path}${k}: must be an integer`); continue; } + if (p.minimum !== undefined && v < p.minimum) e.push(`${path}${k}: >= ${p.minimum}`); + if (p.maximum !== undefined && v > p.maximum) e.push(`${path}${k}: <= ${p.maximum}`); + } + } + return e; +} + +const clipText = (s: string, max = MAX_TEXT): string => (s.length <= max ? s : `${s.slice(0, max)}\n[... ${s.length - max} characters cut]`); +const oneLine = (s: string, max: number): string => { const t = s.replace(/\s+/g, ' ').trim(); return t.length <= max ? t : `${t.slice(0, max - 1)}…`; }; + +// ---- skills (from the package) ---- + +function packageSkillsDir(): string { + const start = dirname(fileURLToPath(import.meta.url)); + for (let d = start; ; d = dirname(d)) { + try { + const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')) as { name?: string }; + if (pkg.name === 'storebound') return join(d, 'skills'); + } catch { /* keep looking */ } + if (dirname(d) === d) break; + } + return resolve(start, '..', 'skills'); +} + +export interface SkillInfo { name: string; description: string; text: string } + +export function loadSkills(dir: string): SkillInfo[] { + if (!existsSync(dir)) return []; + const out: SkillInfo[] = []; + for (const name of readdirSync(dir).filter(n => SKILL_NAME.test(n)).sort()) { + let text: string; + try { text = readFileSync(join(dir, name, 'SKILL.md'), 'utf8'); } catch { continue; } + const { data } = parseFrontMatter(text); + out.push({ name, description: oneLine(typeof data.description === 'string' ? data.description : '', 400), text }); + } + return out; +} + +// ---- pitfalls ---- + +export interface Section { heading: string; body: string } +export function sectionsOf(md: string): Section[] { + const parts = md.split(/^## /m).slice(1); + return parts.map(p => { const nl = p.indexOf('\n'); return { heading: (nl < 0 ? p : p.slice(0, nl)).trim(), body: nl < 0 ? '' : p.slice(nl + 1).trim() }; }); +} + +const APPLE_RE = /app store|apple|testflight|\bios\b|\basc\b|xcode|itunes/i; +const PLAY_RE = /google play|\bplay\b|\bgradle\b|\baab\b|android/i; + +export function searchSections(sections: Section[], query: string, store?: 'apple' | 'play', max = 8): { heading: string; excerpt: string; score: number }[] { + const words = [...new Set(query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(w => w.length > 1))].slice(0, 12); + if (!words.length) return []; + const scored = sections.map(s => { + const head = s.heading.toLowerCase(), body = s.body.toLowerCase(); + let score = 0; + for (const w of words) { if (head.includes(w)) score += 5; const n = body.split(w).length - 1; score += Math.min(n, 5); } + return { s, score }; + }).filter(x => x.score > 0); + const filtered = scored.filter(({ s }) => { + if (!store) return true; + const t = `${s.heading} ${s.body}`; + const a = APPLE_RE.test(t), p = PLAY_RE.test(t); + return store === 'apple' ? a || !p : p || !a; + }); + return filtered.sort((a, b) => b.score - a.score).slice(0, max).map(({ s, score }) => { + // the excerpt starts at the first matching line + const lines = s.body.split('\n'); + const at = Math.max(0, lines.findIndex(l => words.some(w => l.toLowerCase().includes(w)))); + return { heading: s.heading, score, excerpt: clipText(lines.slice(at).join('\n'), 1200) }; + }); +} + +// ---- limits ---- + +export function limits(store?: 'apple' | 'play'): Record { + const specs = (s: 'apple' | 'play') => Object.entries(IMAGE_SPECS).filter(([, v]) => v.store === s).map(([key, v]) => ({ + key, type: v.type, label: v.label, acceptedPixelSizes: v.sizes.map(([w, h]) => `${w}x${h}`), perSet: `${v.min}-${v.max}`, + maxBytes: v.maxBytes, alpha: v.alpha, ...(v.landscapeOnly ? { landscapeOnly: true } : {}), + })); + const previews = (s: 'apple' | 'play') => Object.values(VIDEO_TARGETS).filter(v => v.store === s).map(v => ({ label: v.label, size: `${v.w}x${v.h}` })); + const apple = { + textLimitsCharacters: { ...APPLE_LIMITS }, + keywordField: ['at most 100 characters (characters, not bytes)', 'comma-separated, no spaces around commas (they count)', 'fill at least 90 of the 100', 'no repeats of words already in the name or subtitle (they are indexed anyway)', 'no duplicates', 'single words combine on their own; multi-word phrases waste characters'], + screenshots: specs('apple'), + appPreviews: { targets: previews('apple'), ...PREVIEW_RULES }, + }; + const play = { + textLimitsCharacters: { ...PLAY_LIMITS }, + screenshots: specs('play'), + images: { featureGraphic: '1024x500, JPEG or 24-bit PNG, no alpha', icon: '512x512 PNG (Play listing icon)' }, + promoVideo: previews('play'), + }; + return store === 'apple' ? { apple } : store === 'play' ? { play } : { apple, play }; +} + +// ---- listing lint ---- + +const APPLE_FIELDS = ['name', 'subtitle', 'keywords', 'promotionalText', 'description', 'whatsNew'] as const; +const PLAY_FIELDS = ['title', 'shortDescription', 'fullDescription', 'whatsNew'] as const; +const FIELD_MAX = 5000; + +export function lintListing(store: 'apple' | 'play', fields: Record, locale: string): { problems: { level: string; field: string; msg: string }[]; counts: Record } { + const f = fields as Record; + const counts: Record = {}; + let issues: { level: string; field: string; msg: string }[]; + if (store === 'apple') { + const v = Object.fromEntries(APPLE_FIELDS.filter(k => f[k] !== undefined).map(k => [k, f[k]!])); + issues = appleLocaleIssues(locale, v, ''); + // keywords with no name / subtitle given still get the stand-alone keyword rules + if (v.keywords === undefined) issues = issues.filter(i => i.field !== 'keywords'); + for (const k of APPLE_FIELDS) if (v[k] !== undefined) counts[k] = `${[...v[k]!].length}/${APPLE_LIMITS[k]}`; + } else { + const v = { title: f.title, short: f.shortDescription, full: f.fullDescription, whatsNew: f.whatsNew }; + issues = playLangIssues(locale, v, f.title ?? ''); + for (const [k, lim] of [['title', 'title'], ['shortDescription', 'short'], ['fullDescription', 'full'], ['whatsNew', 'whatsNew']] as const) + if (f[k] !== undefined) counts[k] = `${[...f[k]!].length}/${PLAY_LIMITS[lim]}`; + const rename: Record = { short: 'shortDescription', full: 'fullDescription' }; + issues = issues.map(i => ({ ...i, field: rename[i.field] ?? i.field, msg: i.msg.replace(/^(short|full) is /, (_m, w: string) => `${rename[w]} is `) })); + } + // fields not given are not "missing": only the ones the caller sent are judged + const given = new Set(Object.keys(fields)); + const problems = issues.filter(i => !/is required$|description is required$/.test(i.msg) || given.has(i.field)); + return { problems: problems.map(i => ({ level: i.level, field: i.field, msg: i.msg })), counts }; +} + +// ---- iTunes search ---- + +interface ItunesApp { trackId?: number; trackName?: string; sellerName?: string; averageUserRating?: number; userRatingCount?: number; primaryGenreName?: string } + +// Apple allows about 20 search calls a minute per IP: one shared budget for the whole server, whoever asks +const SEARCH_PER_MIN = 20; +const searchTimes: number[] = []; +export function searchBudget(now = Date.now()): { ok: true } | { ok: false; retryAfter: number } { + while (searchTimes.length && searchTimes[0]! <= now - 60_000) searchTimes.shift(); + if (searchTimes.length >= SEARCH_PER_MIN) return { ok: false, retryAfter: Math.ceil((searchTimes[0]! + 60_000 - now) / 1000) }; + searchTimes.push(now); + return { ok: true }; +} + +export async function searchApps(term: string, country: string, limit: number, base: string, f: typeof fetch): Promise<{ results: Record[] }> { + const b = searchBudget(); + if (!b.ok) throw new Error(`the App Store search is busy (Apple allows ${SEARCH_PER_MIN} calls a minute for this server): try again in ${b.retryAfter} s`); + const url = `${base}${base.includes('?') ? '&' : '?'}${new URLSearchParams({ term, country: country.toLowerCase(), media: 'software', entity: 'software', limit: String(limit) })}`; + const r = await f(url, { signal: AbortSignal.timeout(10_000), headers: { accept: 'application/json' } }); + if (!r.ok) throw new Error(`the App Store search answered ${r.status}`); + const j = (await r.json()) as { results?: ItunesApp[] }; + const results = (j.results ?? []).slice(0, limit).map((a, n) => ({ + rank: n + 1, name: oneLine(a.trackName ?? '', 120), developer: oneLine(a.sellerName ?? '', 120), id: a.trackId ?? null, + rating: typeof a.averageUserRating === 'number' ? Math.round(a.averageUserRating * 100) / 100 : null, + ratingCount: a.userRatingCount ?? 0, genre: a.primaryGenreName ?? null, + })); + return { results }; +} + +// ---- the tools ---- + +const store: Schema = { type: 'string', enum: ['apple', 'play'] }; + +export function publicTools(deps: PublicDeps = {}): PublicTool[] { + let skills: SkillInfo[] | null = null; + const getSkills = () => (skills ??= loadSkills(deps.skillsDir ?? packageSkillsDir())); + const obj = (properties: Record, required?: string[]): PublicTool['inputSchema'] => ({ type: 'object', properties, ...(required ? { required } : {}), additionalProperties: false }); + const textField = (description: string, maxLength = FIELD_MAX): Schema => ({ type: 'string', description, maxLength }); + const locale: Schema = { type: 'string', description: 'Locale or language id, e.g. en-US (default en-US)', pattern: '^[A-Za-z]{2,3}([-_][A-Za-z0-9]{2,8}){0,2}$', maxLength: 20 }; + + return [ + { name: 'skills_list', title: 'List storebound skills', description: 'Name and one-line description of every storebound skill (step-by-step guides for store releases, listings, screenshots, IAP, privacy, ASO ...).', + inputSchema: obj({}), + run: () => { const list = getSkills().map(s => ({ name: s.name, description: s.description })); return { text: list.map(s => `${s.name}: ${s.description}`).join('\n'), data: { skills: list } }; } }, + { name: 'skill_get', title: 'Read one skill', description: 'The full SKILL.md text of one skill (name from skills_list).', + inputSchema: obj({ name: { type: 'string', description: 'Skill name, e.g. aso-basics', pattern: SKILL_NAME.source, minLength: 1, maxLength: 80 } }, ['name']), + run: i => { + const s = getSkills().find(x => x.name === i.name); + if (!s) return { text: `Unknown skill: ${String(i.name)}. Call skills_list for the names.`, isError: true }; + return { text: clipText(s.text, 120_000), data: { name: s.name } }; + } }, + { name: 'pitfalls_search', title: 'Search known store pitfalls', description: 'Sections of the store-pitfalls skill (mistakes made in real App Store and Google Play releases) that match the query words, best first (at most 8, each clipped).', + inputSchema: obj({ query: { type: 'string', description: 'Words to look for, e.g. "keywords rejected screenshots"', minLength: 2, maxLength: 200 }, store }, ['query']), + run: i => { + const sk = getSkills().find(x => x.name === 'store-pitfalls'); + if (!sk) return { text: 'The store-pitfalls skill is not in this package.', isError: true }; + const hits = searchSections(sectionsOf(sk.text), String(i.query), i.store as 'apple' | 'play' | undefined); + if (!hits.length) return { text: 'No pitfall matches those words. Try fewer or different words.', data: { matches: [] } }; + return { text: clipText(hits.map(h => `## ${h.heading}\n${h.excerpt}`).join('\n\n---\n\n')), data: { matches: hits.map(h => ({ heading: h.heading, score: h.score })) } }; + } }, + { name: 'store_limits', title: 'Store listing and image limits', description: 'Listing field character limits, the App Store keyword field rules, screenshot and app preview sizes per device class and the Google Play feature graphic and icon sizes.', + inputSchema: obj({ store }), + run: i => { const d = limits(i.store as 'apple' | 'play' | undefined); return { text: JSON.stringify(d, null, 2), data: d }; } }, + { name: 'listing_lint', title: 'Lint a store listing', description: 'Runs storebound\'s listing and ASO checks on the text you give (limits, keyword field rules, repeated name words, other platforms or apps named, placeholder text, keyword stuffing). Only the fields you send are judged. Nothing is stored.', + inputSchema: obj({ + store, locale, + fields: { type: 'object', description: 'Apple: name, subtitle, keywords, promotionalText, description, whatsNew. Play: title, shortDescription, fullDescription, whatsNew.', additionalProperties: false, + properties: Object.fromEntries([...new Set([...APPLE_FIELDS, ...PLAY_FIELDS])].map(k => [k, textField(`The ${k} text`)])) }, + }, ['store', 'fields']), + run: i => { + const st = i.store as 'apple' | 'play'; + const fields = i.fields as Record; + const allowed: readonly string[] = st === 'apple' ? APPLE_FIELDS : PLAY_FIELDS; + const bad = Object.keys(fields).filter(k => !allowed.includes(k)); + if (bad.length) return { text: `Invalid arguments: fields: ${bad.join(', ')} not used for ${st}; use ${allowed.join(', ')}`, isError: true }; + if (!Object.keys(fields).length) return { text: 'Invalid arguments: fields: send at least one field', isError: true }; + const r = lintListing(st, fields, String(i.locale ?? 'en-US').replace('_', '-')); + const lines = r.problems.map(p => `${p.level.toUpperCase()} ${p.field}: ${p.msg}`); + return { text: `${Object.entries(r.counts).map(([k, v]) => `${k} ${v}`).join(', ')}\n${lines.length ? lines.join('\n') : 'No problems found.'}`, data: r }; + } }, + { name: 'aso_search', title: 'Search the App Store', description: 'The public iTunes Search API: the top apps for a search term in one storefront (name, developer, id, rating, rating count, genre). The order is close to, not the same as, App Store search.', + network: true, + inputSchema: obj({ term: { type: 'string', description: 'Search term', minLength: 1, maxLength: 100 }, country: { type: 'string', description: 'Two-letter storefront, default us', pattern: '^[A-Za-z]{2}$', minLength: 2, maxLength: 2 }, + limit: { type: 'integer', description: 'Results, 1-25 (default 10)', minimum: 1, maximum: 25 } }, ['term']), + run: async i => { + try { + const r = await searchApps(String(i.term), String(i.country ?? 'us'), Number(i.limit ?? 10), deps.searchBase ?? SEARCH_BASE, deps.fetch ?? fetch); + return { text: r.results.map(a => `${a.rank}. ${String(a.name)} (${String(a.developer)}) id ${String(a.id)}, ${a.rating ?? 'no rating'} / ${String(a.ratingCount)} ratings, ${String(a.genre)}`).join('\n') || 'No results.', data: r }; + } catch (e) { return { text: `Search failed: ${(e as Error).message}`, isError: true }; } + } }, + ]; +} + +/** The surface handle() serves in public mode: only these tools exist (the type lives in mcp.ts). */ + +export function publicSurface(deps: PublicDeps = {}): Surface { + const tools = publicTools(deps); + return { + instructions: PUBLIC_INSTRUCTIONS, + list: () => tools.map(t => ({ name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema, + annotations: { title: t.title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: !!t.network } })), + call: async (name, input) => { + const t = tools.find(x => x.name === name); + if (!t) return null; + const problems = validatePublic(t.inputSchema, input); + if (problems.length) return { text: `Invalid arguments: ${problems.join('; ')}`, isError: true }; + const r = await t.run((input ?? {}) as Record); + return { ...r, text: clipText(r.text) }; + }, + }; +} + +// ---- rate limit ---- + +export interface Limiter { hit(key: string): { ok: boolean; retryAfter: number } } + +/** Fixed one-minute window per client key, kept in memory; expired windows are swept so the map stays small. */ +export function createLimiter(perMinute: number, now: () => number = Date.now): Limiter { + const win = new Map(); + let sweptAt = now(); + return { + hit(key) { + const t = now(); + if (t - sweptAt > 60_000 || win.size > 50_000) { for (const [k, w] of win) if (t - w.start >= 60_000) win.delete(k); sweptAt = t; } + let w = win.get(key); + if (!w || t - w.start >= 60_000) { w = { start: t, n: 0 }; win.set(key, w); } + w.n++; + return w.n <= perMinute ? { ok: true, retryAfter: 0 } : { ok: false, retryAfter: Math.max(1, Math.ceil((w.start + 60_000 - t) / 1000)) }; + }, + }; +} + +/** The client key: the first X-Forwarded-For entry only when the proxy is trusted, else the socket address. */ +export function clientKey(forwardedFor: string | string[] | undefined, remote: string | undefined, trustProxy: boolean): string { + if (trustProxy) { + const h = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + const first = h?.split(',')[0]?.trim(); + if (first) return first.slice(0, 64); + } + return remote ?? 'unknown'; +} diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index c7972ae..da72820 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -40,7 +40,7 @@ export const notSentProp = { type: 'boolean', default: false, description: 'Comm /** Every form `console plan` / `console verify` know (cli/lib/console-answers.ts PLAN_FORM_IDS; a test keeps them equal). */ export const CONSOLE_PLAN_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience', 'play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details', 'play-advertising-id', 'play-government-apps', - 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', 'play-games-services', 'play-publish-changes']; + 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', 'play-games-services', 'play-publish-changes', 'apple-app-group']; export const TOOLS: ToolDef[] = [ { name: 'check', title: 'Offline config check', command: ['check'], write: false, network: false, @@ -194,7 +194,7 @@ export const TOOLS: ToolDef[] = [ description: 'Replace the whole Play Data safety form with the answers from the config (CSV import). Skipped when the CSV equals the last upload of this project (hash in .storebound/; force: true sends it anyway). dry_run prints the CSV.', props: { force: { type: 'boolean', default: false, description: 'Upload even when the CSV equals the last upload (after a manual change in Play Console)' } }, args: i => flag('force', i.force) }, { name: 'play_release_testing', title: 'Release to a Play testing track', command: ['play', 'release'], write: true, network: true, - description: 'Upload an AAB (or reuse a versionCode) to a TESTING track (internal, alpha, beta) with release notes. Production is not available here.', + description: 'Upload an AAB (or reuse a versionCode) to a TESTING track (internal, alpha, beta) with release notes. Production is not available through MCP: an update of a published app is `storebound play release --production completed --yes` in a terminal or pipeline (no owner step), the first-ever production release is the owner\'s.', props: { aab: { type: 'string', description: 'Path to the .aab' }, version_code: { type: 'integer', minimum: 1 }, track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, status: { type: 'string', enum: ['completed', 'draft'], default: 'completed' }, not_sent_for_review: notSentProp }, args: i => [...(i.aab ? [String(i.aab)] : []), ...opt('version-code', i.version_code), `--${String(i.track ?? 'internal')}`, String(i.status ?? 'completed'), ...flag('not-sent-for-review', i.not_sent_for_review)] }, @@ -219,7 +219,8 @@ export const TOOLS: ToolDef[] = [ { name: 'apple_appinfo', title: 'App Store app info', command: ['apple', 'appinfo'], write: true, network: true, idempotent: true, description: 'Categories, age rating declaration, content rights, Free price and availability (when missing).' }, { name: 'apple_review_details', title: 'App Review information', command: ['apple', 'review-details'], write: true, network: true, idempotent: true, - description: 'App Review contact and, when reviewAccess.signInRequired, the demo account (both from .storebound/credentials.json or env, masked in the output), plus the review notes (apple.info.reviewNotes, sign-in instructions, where account deletion is).' }, + description: 'App Review contact and, when reviewAccess.signInRequired, the demo account (both from .storebound/credentials.json or env, masked in the output), plus the review notes (apple.info.reviewNotes, or apple.macos.reviewNotes for platform macos; sign-in instructions, where account deletion is). Written to the editable version of the platform.', + props: { platform }, args: i => opt('platform', i.platform) }, { name: 'apple_encryption', title: 'Export compliance answer', command: ['apple', 'encryption'], write: true, network: true, idempotent: true, description: 'Answer export compliance for builds from apple.usesNonExemptEncryption in the config (never guesses).', props: { build: { type: 'string', description: 'Build number (default: every build without an answer)' } }, args: i => opt('build', i.build) }, @@ -412,8 +413,9 @@ export const TOOLS: ToolDef[] = [ description: 'Download a sales or finance report into .storebound/reports/ (gitignored) and print a summary per product and currency (amounts never added across currencies) plus a summary CSV. Apple needs ASC_VENDOR_NUMBER and a team key; Play needs play.reportsBucket and "View financial data". dry_run shows the request only.', props: { kind: { type: 'string', enum: ['apple-sales', 'apple-finance', 'play-earnings', 'play-sales'] }, month: { type: 'string', pattern: '^\\d{4}-\\d{2}$', description: 'YYYY-MM (apple-finance: Apple fiscal month; Play reports)' }, date: { type: 'string', pattern: '^\\d{4}(-\\d{2}(-\\d{2})?)?$', description: 'apple-sales report date (DAILY / WEEKLY YYYY-MM-DD, MONTHLY YYYY-MM, YEARLY YYYY)' }, + days: { type: 'integer', minimum: 1, maximum: 366, description: 'apple-sales / play-sales: the last N days as one per-day JSON' }, from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'apple-sales / play-sales: first day of a range' }, to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'last day of the range (default yesterday)' }, frequency: { type: 'string', enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'] }, type: { type: 'string', enum: ['SALES', 'PRE_ORDER', 'SUBSCRIPTION', 'SUBSCRIPTION_EVENT', 'SUBSCRIBER', 'SUBSCRIPTION_OFFER_CODE_REDEMPTION', 'WIN_BACK_ELIGIBILITY'] } }, - required: ['kind'], args: i => [String(i.kind), ...opt('month', i.month), ...opt('date', i.date), ...opt('frequency', i.frequency), ...opt('type', i.type), '--json'] }, + required: ['kind'], args: i => [String(i.kind), ...opt('month', i.month), ...opt('days', i.days), ...opt('from', i.from), ...opt('to', i.to), ...opt('date', i.date), ...opt('frequency', i.frequency), ...opt('type', i.type), '--json'] }, { name: 'triage', title: 'Crash and feedback triage', command: ['triage'], write: false, network: true, description: 'Read-only: TestFlight crash reports (signature from each crash log), TestFlight screenshot feedback and Google Play error reports of the top issues, grouped by signature with their top devices, OS versions and builds. Tester emails are never read.', props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 14 }, build: { type: 'string', pattern: '^[0-9][0-9.]*$' }, @@ -458,15 +460,19 @@ export const TOOLS: ToolDef[] = [ description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.', props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) }, { name: 'console_plan', title: 'Console form plan (agent-first)', command: ['console', 'plan'], write: false, network: false, - description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form (Play: material radios and checkboxes take a JS click on the inner [role=radio] / [role=checkbox]), a ready-made in-page helper (inPageLoop: one evaluate call times out around 45 s, so long flows run in the page and you poll window.__log) and how to verify. Forms: the App Store and Play forms of `console fill`, plus agent-only Play pages: play-store-settings (the App category, which the Play API cannot set), play-app-content (every App content card in order with its page and one owner batch; all: true inlines every card) 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 (the API cannot set them) and play-production-release (its final Send changes for review is the owner\'s). Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.', + description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form (Play: material radios and checkboxes take a JS click on the inner [role=radio] / [role=checkbox]), a ready-made in-page helper (inPageLoop: one evaluate call times out around 45 s, so long flows run in the page and you poll window.__log) and how to verify. Forms: the App Store and Play forms of `console fill`, plus agent-only Play pages: play-store-settings (the App category, which the Play API cannot set), play-app-content (every App content card in order with its page and one owner batch; all: true inlines every card) 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 (the API cannot set them) and play-production-release (its final Send changes for review is the owner\'s); and apple-app-group (the developer portal step the App Store Connect API cannot do: assign App Groups to an extension\'s App ID; its final Save is the agent\'s own, buttons.finalByAgent; bundleId narrows it, lookup adds the portal deep link). Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.', props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS }, - all: { type: 'boolean', description: 'play-app-content only: every card\'s steps inline in fix order, ending with the one owner list (tick the IARC Terms before, Send changes for review after)' } }, - required: ['form'], args: i => [String(i.form), ...(i.all === true ? ['--all'] : []), '--json'] }, + all: { type: 'boolean', description: 'play-app-content only: every card\'s steps inline in fix order, ending with the one owner list (tick the IARC Terms before, Send changes for review after)' }, + bundleId: { type: 'string', description: 'apple-app-group only: the bundle id (an extension) whose App Group to assign' }, + groups: { type: 'string', description: 'apple-app-group only: comma-separated group ids when the project does not show them (group.com.example.app)' }, + lookup: { type: 'boolean', description: 'apple-app-group only: read the bundle id\'s resource id from the App Store Connect API (a GET) for the portal deep link' } }, + required: ['form'], args: i => [String(i.form), ...(i.all === true ? ['--all'] : []), ...opt('bundle-id', i.bundleId), ...opt('groups', i.groups), ...flag('lookup', i.lookup), '--json'] }, { name: 'console_verify', title: 'Verify a console form', command: ['console', 'verify'], write: false, network: true, - description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text; play-app-content: the JSON of the plan\'s capture snippet; Play store settings and the privacy policy: the page text after Save; the other App content cards (ads, sign-in details, advertising ID, government, financial, health): the JSON of the plan\'s verify.capture snippet, which reads radios and boxes from the DOM: aria-checked, checked, aria-selected). apple-app-record and apple-age-rating without text read the App Store Connect API. Returns a per-item match / mismatch table; exit code 1 on a mismatch.', + description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text; play-app-content: the JSON of the plan\'s capture snippet; Play store settings and the privacy policy: the page text after Save; the other App content cards (ads, sign-in details, advertising ID, government, financial, health): the JSON of the plan\'s verify.capture snippet, which reads radios and boxes from the DOM: aria-checked, checked, aria-selected). apple-app-record and apple-age-rating without text read the App Store Connect API, and so does apple-app-group (the App Groups on the App ID\'s newest readable profile). Returns a per-item match / mismatch table; exit code 1 on a mismatch.', props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS }, - text: { type: 'string', description: 'The page text (or the capture JSON) of the finished form' } }, - required: ['form'], args: i => [String(i.form), ...(typeof i.text === 'string' && i.text ? [`--text=${i.text}`] : []), '--json'] }, + text: { type: 'string', description: 'The page text (or the capture JSON) of the finished form' }, + bundleId: { type: 'string', description: 'apple-app-group only: check this bundle id (default: every target with App Groups); it reads the API, no text' } }, + required: ['form'], args: i => [String(i.form), ...(typeof i.text === 'string' && i.text ? [`--text=${i.text}`] : []), ...opt('bundle-id', i.bundleId), '--json'] }, { name: 'apple_preorder_status', title: 'App Store pre-order status', command: ['apple', 'preorder', 'status'], write: false, network: true, description: 'Read-only: per territory whether the app is available, its release date, pre-order on / off and its content status. Enabling or ending a pre-order is an owner-only terminal command.', args: () => ['--json'] }, ]; diff --git a/cli/mcp.ts b/cli/mcp.ts index 95405cc..6441871 100644 --- a/cli/mcp.ts +++ b/cli/mcp.ts @@ -56,6 +56,13 @@ export const INSTRUCTIONS = [ 'Run `check` (offline) and `validate` (read-only) before writes. Long uploads (images, screenshots) are better run in a terminal with a log.', ].join(' '); +/** A replacement tool surface (the public key-less server, mcp-public.ts): its own instructions, tool list and calls. */ +export interface Surface { + instructions: string; + list(): Record[]; + call(name: string, input: unknown): Promise<{ text: string; data?: unknown; isError?: boolean } | null>; +} + type Id = string | number; interface Msg { jsonrpc: '2.0'; id?: Id; method?: string; params?: Record } @@ -71,7 +78,7 @@ export function eraOf(m: Msg): { era: 'modern' | 'legacy'; version: string | nul } /** Handles one message; returns the response object (or null for notifications). Exported for tests. */ -export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ code: number; out: string }> = runCli): Promise | null> { +export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ code: number; out: string }> = runCli, surface?: Surface): Promise | null> { const err = (code: number, message: string, data?: unknown) => ({ jsonrpc: '2.0', id: m.id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } }); if (m.id === undefined) { if (m.method === 'notifications/cancelled') cancel(m.params?.requestId as Id); @@ -83,7 +90,7 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ protocolVersion: LEGACY.includes(asked) ? asked : LEGACY_PROTOCOL, capabilities: { tools: { listChanged: false } }, serverInfo: { ...serverInfo(), description: pkg.description }, - instructions: INSTRUCTIONS, + instructions: surface?.instructions ?? INSTRUCTIONS, } }; } @@ -101,11 +108,17 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ switch (m.method) { case 'server/discover': - return ok({ supportedVersions: SUPPORTED, capabilities: { tools: {} }, instructions: INSTRUCTIONS, ttlMs: TOOLS_TTL_MS, cacheScope: 'public' }); + return ok({ supportedVersions: SUPPORTED, capabilities: { tools: {} }, instructions: surface?.instructions ?? INSTRUCTIONS, ttlMs: TOOLS_TTL_MS, cacheScope: 'public' }); case 'ping': return ok({}); - case 'tools/list': return ok({ tools: listTools(bypassOn()), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) }); + case 'tools/list': return ok({ tools: surface ? surface.list() : listTools(bypassOn()), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) }); case 'tools/call': { const name = String(m.params?.name ?? ''); + if (surface) { + // public mode: only the surface's own tools exist; nothing below (the CLI tools, owner_step) is reachable + const r = await surface.call(name, m.params?.arguments ?? {}); + if (!r) return err(-32602, `Unknown tool: ${name}`); + return ok({ content: [{ type: 'text', text: r.text }], ...(r.data === undefined ? {} : { structuredContent: { result: r.data } }), isError: !!r.isError }); + } const tool = TOOLS.find(t => t.name === name) ?? (name === OWNER_STEP.name && bypassOn() ? OWNER_STEP : undefined); if (!tool) return err(-32602, `Unknown tool: ${name}`); const input = (m.params?.arguments ?? {}) as Record; diff --git a/cli/play/build.ts b/cli/play/build.ts index 01e9155..cf87d69 100644 --- a/cli/play/build.ts +++ b/cli/play/build.ts @@ -14,7 +14,7 @@ // Before Gradle runs, the app module's build file must read -PversionCode / -PversionName in defaultConfig (a literal // or an env var keeps the AAB at that value; android.injected.version.* does not override it for bundleRelease): a // warning with the exact line to use. After the build, an AAB whose versionCode is not the planned one is a hard stop. -import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { chmodSync, copyFileSync, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { androidSigning } from '../lib/credentials'; import type { Ctx } from '../lib/ctx'; @@ -108,6 +108,11 @@ export async function run(ctx: Ctx): Promise { shipPhase('build', `${plan.task}, versionCode ${code || '?'}`); const started = Date.now(); mkdirSync(out, { recursive: true }); + // a gradlew committed from Windows has no exec bit: macOS / Linux then say "Executable not found: ./gradlew" + if (hasWrapper && process.platform !== 'win32') { + const w = join(t.dir, 'gradlew'); + if ((statSync(w).mode & 0o111) === 0) { chmodSync(w, statSync(w).mode | 0o755); log(TAG, `${relative(root, w)} had no exec bit (committed from Windows?): made it executable; commit it with git update-index --chmod=+x`); } + } const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...androidSdkEnv(sdk), ...signingEnv(signing) } }); const lines: string[] = []; const each = (line: string): void => { diff --git a/cli/play/client.ts b/cli/play/client.ts index 647b893..290b2ea 100644 --- a/cli/play/client.ts +++ b/cli/play/client.ts @@ -1,13 +1,13 @@ // Google Play Developer API (androidpublisher v3) client: JSON + media calls with retry, and the edit lifecycle. -import { playServiceAccount } from '../lib/credentials'; -import { tokenSource } from '../lib/google-auth'; +import { PLAY_SCOPE, playToken } from '../lib/google-auth'; +import { accessError } from '../lib/google-access'; import { log, warn } from '../lib/log'; import { errorBody } from '../lib/http'; import { noteGoogle } from '../lib/quota'; import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert'; import * as rt from '../lib/rt'; import { withMachineLock } from '../lib/lock'; -import { clearPending, isReviewRefused, notSentMessage, publishingUrl, recordPending, withNotSent } from './review-pending'; +import { clearPending, isAutoSendOnly, isReviewRefused, notSentMessage, publishingUrl, recordPending, withNotSent } from './review-pending'; /** A read's edit is gone: replaced by a newer edit of the app, committed elsewhere or expired. */ export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already been committed|edit.*(expired|not found)|outside of this Edit/i; @@ -15,7 +15,8 @@ export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already bee export type PlayClient = Awaited>; export async function playClient(root: string, packageName: string) { - const token = tokenSource(playServiceAccount(root)); + // the credential broker's token when one is configured, else the local service account key (cli/lib/google-auth.ts) + const token = playToken(root, PLAY_SCOPE, { packageName }); const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}`; const UPLOAD = `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}`; @@ -49,6 +50,8 @@ export async function playClient(root: string, packageName: string) { const msg = /^\s*= 500 && !safe) throw new AmbiguousWriteError(`${method} ${short()} -> ${r.status} ${msg}`, r.status); if (r.status === 404 && method === 'DELETE' && attempt > 0) return {} as T; + // a disabled API or a missing Play permission: the exact API / grant and its link, never retried (pitfall 122) + if (r.status === 403 || r.status === 401) { const g = await accessError(`${method} ${short()} -> ${r.status} ${msg}`, { app: packageName, root, source: token.source() }); if (g) throw g; } throw new Error(`${method} ${short()} -> ${r.status} ${msg}`); } } @@ -115,9 +118,15 @@ export async function playClient(root: string, packageName: string) { } }; try { await commit(); } catch (err) { - if (notSent || !isReviewRefused(err)) throw err; - refused(); - await commit(); + if (notSent && isAutoSendOnly(err)) { + notSent = false; + log('play', 'Play answered "Changes are sent for review automatically" (managed publishing is off): committing again without changesNotSentForReview; Play sends the changes for review itself'); + await commit(); + } else { + if (notSent || !isReviewRefused(err)) throw err; + refused(); + await commit(); + } } openEdit = null; log('play', `edit ${id} committed${notSent ? ' with changesNotSentForReview=true' : ''}`); @@ -141,6 +150,8 @@ export async function playClient(root: string, packageName: string) { return { API, UPLOAD, packageName, call, + /** Where this client's tokens come from: "broker" or "file" (no values). */ + credentialSource: () => token.source(), /** * Read-only edit: opens one, runs fn (GETs only), then deletes it, also on error. Never validated or committed. * A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger). diff --git a/cli/play/games.ts b/cli/play/games.ts index 009683f..edee088 100644 --- a/cli/play/games.ts +++ b/cli/play/games.ts @@ -9,11 +9,12 @@ // configuration with the fields Play has and we do not manage (icon, sort rank) kept. Nothing is published: the // owner's Publish in Play Console makes the draft live. import { playServiceAccount } from '../lib/credentials'; +import type { PlayToken } from '../lib/google-auth'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { dryPlan } from '../lib/dry'; import { hasAndroidProject, type ItemPlan, pgsFindings, type PlayExisting, planPlayGames, planRows, readPgsProject, usesPlayGames } from '../lib/game-services'; -import { PLAY_SCOPE, tokenSource } from '../lib/google-auth'; +import { PLAY_SCOPE, playToken } from '../lib/google-auth'; import { enableUrl, findingText, parseGoogleError } from '../lib/google-errors'; import { errorBody } from '../lib/http'; import { done, log, warn } from '../lib/log'; @@ -36,13 +37,24 @@ API: ${GAMES_API_TITLE} (${GAMES_SERVICE}). Enable it, wait a minute, then run s } export function gamesConfigClient(root: string) { - const account = playServiceAccount(root); - const token = tokenSource(account, PLAY_SCOPE); + // reads: the credential broker's cloud-platform.read-only token (androidpublisher does not cover this API), else the + // local key; writes stay on the local key (the broker has no write scope for it): made only when a write is sent + let projectId: string | null = null; + try { projectId = playServiceAccount(root).project_id ?? null; } catch { /* broker only */ } + const readToken = playToken(root, PLAY_SCOPE, { brokerScope: 'cloud-platform.read-only' }); + let writeToken: PlayToken | null = null; + const token = (method: string): Promise => { + if (method === 'GET') return readToken(); + try { writeToken ??= playToken(root, PLAY_SCOPE, { brokerScope: null }); } catch (e) { + throw new Error(`Play Games configuration writes need a local service account key (PLAY_SERVICE_ACCOUNT): the credential broker hands out read scopes only for this API. ${(e as Error).message}`); + } + return writeToken(); + }; async function call(method: string, path: string, body?: unknown): Promise { const url = GAMES_CONFIG_API + path; const safe = method !== 'POST' && retrySafe(method, url); for (let attempt = 0; ; attempt++) { - const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) }); + const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token(method)), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) }); if (r.status === 429 && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; } if (r.status >= 500) { if (safe && attempt < 4) { await r.body?.cancel(); await rt.sleep(2000 * (attempt + 1)); continue; } @@ -53,7 +65,7 @@ export function gamesConfigClient(root: string) { const t = await r.text(); if (!r.ok) { const msg = `${method} ${path} -> ${r.status} ${errorBody(t, 800)}`; - const off = r.status === 403 ? gamesApiDisabledText(msg, account.project_id ?? null) : null; + const off = r.status === 403 ? gamesApiDisabledText(msg, projectId) : null; throw new Error(off ? `${msg.split(' -> ')[0]} -> 403: ${off}` : msg); } return (t ? JSON.parse(t) : {}) as T; diff --git a/cli/play/google.ts b/cli/play/google.ts index 9691763..cc61886 100644 --- a/cli/play/google.ts +++ b/cli/play/google.ts @@ -1,7 +1,6 @@ // Small client for the other Google APIs storebound reads with the same Play service account: the Play Developer // Reporting API (vitals) and Cloud Storage (Play statistics exports). Read-only callers; retries 429 / 5xx. -import { playServiceAccount } from '../lib/credentials'; -import { tokenSource } from '../lib/google-auth'; +import { playToken } from '../lib/google-auth'; import { errorBody } from '../lib/http'; import { noteGoogle } from '../lib/quota'; import * as rt from '../lib/rt'; @@ -9,7 +8,8 @@ import * as rt from '../lib/rt'; export const REPORTING_API = 'https://playdeveloperreporting.googleapis.com/v1beta1'; export function googleClient(root: string, scope: string) { - const token = tokenSource(playServiceAccount(root), scope); + // the credential broker when it hands out this scope, else the local service account key + const token = playToken(root, scope); async function send(method: string, url: string, body?: unknown): Promise { for (let attempt = 0; ; attempt++) { const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, diff --git a/cli/play/images.ts b/cli/play/images.ts index de181b2..76ed75c 100644 --- a/cli/play/images.ts +++ b/cli/play/images.ts @@ -2,8 +2,9 @@ // language, not of the whole upload). // storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts] [--not-sent-for-review] // Languages: the ones `play listing` pushes (a status=draft or status=translate section is skipped unless -// --include-drafts) and that have a listing on Play. A language Play has no listing for yet (404 "Listing for language -// ... not found") is skipped with an info line, never an error: run `play listing` first. +// --include-drafts). A language Play has no listing for yet (404 "Listing for language ... not found") is created by +// this run from its text in the listing file, in the same edit as all its images (Play commits a new language only with +// icon, feature graphic and 2+ phone screenshots in it; `play listing` does the same, from either command). // Per Play language, from //: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*) and // phoneScreenshots (play/NN.*, at least 2) are required (Play Console Help answer 9866151). Optional, uploaded only // when present: sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*), tvScreenshots (tv/NN.*), @@ -26,7 +27,7 @@ import { draftReason, draftSections } from '../lib/listing-plan'; import { done, log, step } from '../lib/log'; import { playClient } from './client'; import { pulledChecksum } from '../lib/art-pull'; -import { readPlayCopy } from './listing'; +import { listingBody, readPlayCopy } from './listing'; import { variantRoot } from '../shots/lib'; import { resolvedTablets } from '../shots/report'; import { consoleLink } from '../lib/console-links'; @@ -72,16 +73,34 @@ export function missingChange(type: string, remote: number, prune: boolean): Ima /** What one language uploads: the sets with files, the optional types without, and errors (missing required sets). */ export interface LangImages { sets: [string, string[]][]; missing: string[]; errors: string[] } -/** The graphics a NEW Play language needs before its edit can commit (403 "no high resolution icon set for language"): - * the hi-res icon and, when rendered, the feature graphic. `play listing` uploads them in the same edit as the text, - * so listing and images never wait for each other (pitfall 102). */ -export function firstGraphics(ctx: Ctx, code: string): [string, string][] { +/** Everything Play wants in the same edit that creates a NEW language, or the commit is refused (403 "no high + * resolution icon set for language X" / "too few screenshots for language X"): icon, feature graphic, at least 2 phone + * screenshots and, when the app's other languages have them (or play.tablets is true), the two tablet sets. A set the + * language's renders folder lacks falls back to the default language's (a note); what is still missing is an error + * with the exact fix, raised in the dry run, never at commit time (pitfall 102). */ +export interface NewLangImages { code: string; uploads: [string, string[]][]; notes: string[]; errors: string[] } +export function newLanguageImages(ctx: Ctx, code: string, defaultCode: string, tabletsOnPlay: boolean): NewLangImages { const { config, resolve } = ctx.loaded; const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png')); - const l = byPlayCode(config.languages).get(code); - const src = resolve(config.renders ?? config.shots?.out ?? 'store/renders'); - const li = langImages(code, l ? join(src, l.id) : join(src, '__none__'), icon, false); - return li.sets.filter(([t]) => t === 'icon' || t === 'featureGraphic').map(([t, files]) => [t, files[0]!] as [string, string]); + const langOf = byPlayCode(config.languages); + const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants); + const tablets = config.play?.tablets === undefined ? resolvedTablets(config, src) || tabletsOnPlay : config.play.tablets; + const dirOf = (c: string) => join(src, langOf.get(c)?.id ?? '__none__'); + const own = langImages(code, dirOf(code), icon, tablets); + const def = code === defaultCode ? null : langImages(defaultCode, dirOf(defaultCode), icon, tablets); + const required = ['icon', 'featureGraphic', 'phoneScreenshots', ...(tablets ? PLAY_TABLET_TYPES : [])]; + const sets = new Map(own.sets); + const notes: string[] = [], errors: string[] = []; + for (const type of required) { + const mine = sets.get(type), fb = def?.sets.find(([t]) => t === type)?.[1]; + const short = type === 'phoneScreenshots' || PLAY_TABLET_TYPES.includes(type); + if (mine && (!short || mine.length >= 2)) continue; + if (fb && (!short || fb.length >= 2)) { sets.set(type, fb); notes.push(`${code}: no ${type} of its own, using ${defaultCode}'s (${fb.length}); render ${code}'s with storebound shots render`); continue; } + const why = own.errors.find(e => e.includes(type === 'icon' ? 'no icon' : type)) ?? `${code}: ${type} missing`; + errors.push(`${why}. Play refuses to commit a new language without it (403 too few screenshots / no high resolution icon)`); + } + const uploads = [...sets].filter(([t, f]) => f.length && (tablets || !PLAY_TABLET_TYPES.includes(t))); + return { code, uploads, notes, errors }; } /** The image sets of one language folder (`tablets` = resolved play.tablets). Reads the folder only. */ @@ -180,7 +199,7 @@ export async function run(ctx: Ctx): Promise { (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? []; // one read-only edit: what Play has now (sha256 per image, in order) against the local files, per language and type; // for an optional type with no local files only how many Play has (kept, or deleted with --prune) - // a language with no listing on Play yet answers 404: skipped (play listing creates it), never fatal + // a language with no listing on Play yet answers 404: created in its edit (below), never fatal const plan = new Map(); const unlisted: string[] = []; await play.read(async id => { @@ -197,14 +216,21 @@ export async function run(ctx: Ctx): Promise { plan.set(code, out); } }); - for (const code of unlisted) log('play', `skipped ${code}: no Play listing yet (run storebound play listing first; a draft section needs review or --include-drafts)`); + // a language with text in the listing file but no Play listing yet: this run creates it (the default text of the file) + // together with all its images in one edit, which Play only commits as a whole (pitfall 102) + const copyOf = new Map(readPlayCopy(ctx).map(c => [c.lang, c])); + for (const code of unlisted) { + const li = sets.get(code)!; + plan.set(code, [...li.sets.map(([type, files]) => imageChange(type, [], files.map(storeSha))), ...li.missing.map(t => missingChange(t, 0, prune))]); + log('play', `${code}: no Play listing yet, this run creates it from the listing file with its images`); + } codes = codes.filter(c => plan.has(c)); const writes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace' || c.change === 'delete'); if (ctx.args.has('dry')) { let changed = 0; for (const [i, code] of codes.entries()) { changed += writes(code).length; - log('play', `${step(i + 1, codes.length, code)}: ${describePlan(plan.get(code)!)}`); + log('play', `${step(i + 1, codes.length, code)}: ${describePlan(plan.get(code)!)}${unlisted.includes(code) ? ' (and creates the listing)' : ''}`); } done('play', `dry run: ${changed ? changed + ' image set(s) would change' : 'every image set is already on Play'}; read-only, nothing uploaded`); return; @@ -221,6 +247,7 @@ export async function run(ctx: Ctx): Promise { const li = sets.get(code)!; await play.edit(async (id, ctl) => { let n = 0, types = 0, deleted = 0; + if (unlisted.includes(code)) await play.call('PUT', `${play.API}/edits/${id}/listings/${code}`, listingBody(copyOf.get(code)!, config.play?.video)); await Promise.all([ ...li.sets.map(async ([type, files]) => { const remote = await remoteOf(id, code, type); diff --git a/cli/play/listing.ts b/cli/play/listing.ts index f0dce56..698a1f1 100644 --- a/cli/play/listing.ts +++ b/cli/play/listing.ts @@ -20,7 +20,7 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, step, warn } from '../lib/log'; import { playClient } from './client'; -import { firstGraphics } from './images'; +import { newLanguageImages, type NewLangImages } from './images'; import { stdinIsTTY } from '../lib/tty'; /** Play release notes that name iOS, iPhone, App Store, ... are refused before any edit, dry runs included (pitfall 77). */ @@ -140,12 +140,40 @@ export async function run(ctx: Ctx): Promise { if (!a.has('json')) log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}${config.play?.video ? ` · promo video ${config.play.video}` : ''}`); const play = await playClient(root, pkg); const bodies = copy.map(c => listingBody(c, config.play?.video)); + // A language Play does not have yet only commits with its icon, feature graphic, 2+ phone screenshots (and the + // tablet sets when the other languages have them) in the same edit: planned here so the dry run refuses early. + const fresh = new Map(); + const planFresh = async (id: string, now: PlayListing[]) => { + const missing = bodies.filter(b => !now.some(n => n.language === b.language)).map(b => b.language!); + if (!missing.length) return; + const ref = now.find(n => n.language === details.defaultLanguage) ?? now[0]; + let tabletsOnPlay = false; + if (ref) for (const t of ['sevenInchScreenshots', 'tenInchScreenshots']) { + try { tabletsOnPlay ||= !!((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${id}/listings/${ref.language}/${t}`)).images?.length); } catch { /* no tablet set */ } + } + for (const code of missing) { + const f = newLanguageImages(ctx, code, details.defaultLanguage, tabletsOnPlay); + // the very first listing of an app: Play checks the images at release, not at this commit, so only a warning + if (!now.length && f.errors.length) { f.notes.push(...f.errors.map(e => `warning: ${e}`)); f.errors = []; } + fresh.set(code, f); + } + }; const p = await play.read(async id => { const cur = await play.call>('GET', `${play.API}/edits/${id}/details`); const now = (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []; + await planFresh(id, now); return playPlan(cur, details, now, bodies, { skipped, notes, only: a.has('only') }); }); + for (const f of fresh.values()) { + p.notes.push(...f.notes); + if (!f.errors.length) p.notes.push(`${f.code} is new on Play: uploads ${f.uploads.map(([t, fl]) => `${t} (${fl.length})`).join(', ')} in the same edit`); + } const dry = a.has('dry'); + const refuse = [...fresh.values()].flatMap(f => f.errors); + if (refuse.length) { + if (a.has('json')) printJson({ dry, ...p, errors: refuse }); else for (const line of playPlanLines(p)) console.log(line); + throw new Error(['a new Play language needs its images in the same edit:', ...refuse].join('\n')); + } if (a.has('json')) printJson({ dry, ...p }); else for (const line of playPlanLines(p)) console.log(line); if (dry) { done('play', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; } @@ -168,13 +196,11 @@ export async function run(ctx: Ctx): Promise { for (const [i, b] of todo.entries()) { await play.call('PUT', `${play.API}/edits/${id}/listings/${b.language}`, b); log('play', step(i + 1, todo.length, b.language!)); - // a language Play does not have yet: its edit only commits with a hi-res icon (pitfall 102) + // a language Play does not have yet: its edit only commits with its images in it (pitfall 102) if (!now.some(n => n.language === b.language)) { - const graphics = firstGraphics(ctx, b.language!); - if (!graphics.some(([t]) => t === 'icon')) warn('play', `${b.language}: no 512x512 icon to send with this new language (play.icon or store/icons/play-512.png; storebound icons makes it): Play may refuse the commit with 403 "no high resolution icon"`); - for (const [type, f] of graphics) { - await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); - log('play', `${b.language}: ${type} uploaded with the new listing`); + for (const [type, files] of fresh.get(b.language!)?.uploads ?? []) { + for (const f of files) await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); + log('play', `${b.language}: ${type} (${files.length}) uploaded with the new listing`); } } } diff --git a/cli/play/old-builds.ts b/cli/play/old-builds.ts new file mode 100644 index 0000000..5b039ec --- /dev/null +++ b/cli/play/old-builds.ts @@ -0,0 +1,42 @@ +// Builds still active on a testing track below the production build (pitfall 129). Play reviews every active build +// with each submission: a policy issue in an old one (a media permission it asked for, "Use alternative system +// pickers") blocks "Send changes for review" for the whole app, while the API still reads production as completed. +// The Developer API cannot read a bundle's permissions, so each old build is named with the command that replaces it. +// Pure. + +export interface TrackLike { track: string; releases?: { name?: string; versionCodes?: string[]; status: string }[] } +export interface OldBuild { track: string; versionCodes: number[]; name: string | null; status: string; productionCode: number } + +/** Permissions Play reviews as policy-sensitive (Photo and video permissions, All files access). */ +export const SENSITIVE_PERMISSIONS = ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO', 'READ_EXTERNAL_STORAGE', 'MANAGE_EXTERNAL_STORAGE', 'WRITE_EXTERNAL_STORAGE']; + +const ACTIVE = new Set(['completed', 'inProgress', 'halted']); +const codes = (r: { versionCodes?: string[] }): number[] => (r.versionCodes ?? []).map(Number).filter(Number.isFinite); + +export function oldTrackBuilds(tracks: TrackLike[]): OldBuild[] { + const prod = tracks.find(t => t.track === 'production'); + const prodCodes = (prod?.releases ?? []).filter(r => ACTIVE.has(r.status)).flatMap(codes); + if (!prodCodes.length) return []; + const productionCode = Math.max(...prodCodes); + const out: OldBuild[] = []; + for (const t of tracks) { + if (t.track === 'production') continue; + for (const r of t.releases ?? []) { + const cs = codes(r); + if (!ACTIVE.has(r.status) || !cs.length || Math.max(...cs) >= productionCode) continue; + out.push({ track: t.track, versionCodes: cs, name: r.name ?? null, status: r.status, productionCode }); + } + } + return out; +} + +/** The track flag of `play release` for a track name (custom closed tracks go by --track). */ +const trackFlag = (track: string): string => (['internal', 'alpha', 'beta'].includes(track) ? `--${track}` : `--track ${track}`); + +export function oldBuildLines(old: OldBuild[]): string[] { + if (!old.length) return []; + return [ + ` old builds still active (${old.length}): Play reviews them with every submission; a policy issue in one (e.g. ${SENSITIVE_PERMISSIONS.slice(0, 2).join(' / ')}) blocks Send changes for review. The API cannot read their permissions:`, + ...old.map(o => ` ${o.track} [${o.versionCodes.join(',')}] ${o.name ?? ''} ${o.status} -> replace: storebound play release --version-code ${o.productionCode} ${trackFlag(o.track)} completed`), + ]; +} diff --git a/cli/play/release.ts b/cli/play/release.ts index 53fd29b..7ba7c8a 100644 --- a/cli/play/release.ts +++ b/cli/play/release.ts @@ -1,10 +1,14 @@ // Upload an AAB (or reuse an uploaded versionCode) and put it on one or more tracks in one edit, with the "What's new" // of every listing language as release notes. -// storebound play release app-release.aab [--internal completed] [--production draft] [--name "1.0.0 (3)"] [--dry] +// storebound play release app-release.aab [--internal completed] [--production draft] [--name "1.0.0 (3)"] [--first-release] [--dry] // storebound play release --version-code 3 --production draft // storebound play release app.aab --production completed --not-sent-for-review (commit with changesNotSentForReview=true) // Tracks: internal, alpha (closed), beta (open), production; status completed | draft | inProgress | halted. -// Without a track flag: internal, completed. A production release that is not a draft asks for confirmation. +// Without a track flag: internal, completed. A production release that is not a draft asks for confirmation (--yes in +// scripts). An UPDATE of a published app (the production track holds a completed / inProgress / halted release) needs +// no owner step: `play release --production completed --yes` runs in a pipeline (cli/lib/release-kind.ts). The +// first-ever production release with --yes is exit 3 OWNER_STEP unless the owner approved it (--first-release, or +// bypass mode). // Draft app (never published): Play only accepts status "draft" on any track; the first production release is sent // from the Console (see the play-first-release skill). import { readFileSync, statSync } from 'node:fs'; @@ -19,6 +23,7 @@ import { usedVersionCodes } from './next-version-code'; import { readPlayCopy, refusePlayNotes } from './listing'; import { shipNoop, shipPhase, shipReport } from '../lib/ship-step'; import { consoleUrl, playTrackPage } from '../lib/console-links'; +import { enforceFirstRelease, playReleaseKind } from '../lib/release-kind'; const TRACKS = ['internal', 'alpha', 'beta', 'production']; @@ -49,8 +54,13 @@ export async function run(ctx: Ctx): Promise { for (const t of tracks) if (!STATUSES.includes(t.status)) throw new Error(`--${t.track}: status must be one of ${STATUSES.join(', ')}`); const fraction = a.get('fraction'); const live = tracks.find(t => t.track === 'production' && t.status !== 'draft'); + const gated = tracks.find(t => t.track === 'production' && (t.status === 'completed' || t.status === 'inProgress')); const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew })); refusePlayNotes(ctx, notes, `play release notes${a.has('dry') ? ' (dry run)' : ''}`); + // update of a published app: no owner step; the first production release: the owner's (exit 3 OWNER_STEP with --yes). + // The kind is read inside the release's own edit (no extra edit), before anything is uploaded. + const seen: { kind?: ReturnType } = {}; + let refused = false; if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return; if (aab) { @@ -64,14 +74,28 @@ export async function run(ctx: Ctx): Promise { let nothing = false; try { await play.edit(async (id, ctl) => { + if (gated) { + const prod = (await play.call<{ releases?: PlayTrackRelease[] }>('GET', `${play.API}/edits/${id}/tracks/production`).catch(() => ({ releases: [] }))).releases ?? []; + const kind = (seen.kind = playReleaseKind(prod)); + if (a.has('dry')) log('play', `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`); + else if (!enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) { refused = true; ctl.unchanged(); return; } + } let code = fixed ?? ''; let uploaded = false; + // a dry run never uploads: the bundle would go into an edit that is thrown away (14 MB for nothing, and a long + // upload can outlive the edit: "This edit has expired"); its tracks are shown, not PUT (Play refuses a track + // with a versionCode the edit does not have) + let planOnly = false; if (aab) { // a versionCode Play already has (an earlier run uploaded it) is reused, never uploaded twice const info = aabInfo(aab); if ((await usedVersionCodes(play, id)).includes(info.versionCode)) { code = String(info.versionCode); log('play', `versionCode ${code} is on Play already: not uploaded again`); + } else if (a.has('dry')) { + code = String(info.versionCode); + planOnly = true; + log('play', `dry run: would upload ${aab} (${(statSync(aab).size / 1048576).toFixed(1)} MB, versionCode ${code}); not uploaded`); } else { shipPhase('upload', `uploading ${aab}`, 0); log('play', `uploading ${aab} (${(statSync(aab).size / 1048576).toFixed(1)} MB), this can take minutes`); @@ -92,6 +116,7 @@ export async function run(ctx: Ctx): Promise { if (t.status === 'inProgress') release.userFraction = Number(fraction ?? 0.1); const cur = (await play.call<{ releases?: PlayTrackRelease[] }>('GET', `${play.API}/edits/${id}/tracks/${t.track}`).catch(() => ({ releases: [] }))).releases ?? []; if (sameRelease(cur, release)) { log('play', `${t.track}: already ${t.status} ${code}, unchanged`); continue; } + if (planOnly) { log('play', `dry run: ${t.track} <- ${code} (${t.status}, notes in ${notes.length} languages)`); changed++; continue; } await play.call('PUT', `${play.API}/edits/${id}/tracks/${t.track}`, { track: t.track, releases: [release] }); changed++; log('play', `${t.track} <- ${code} (${t.status}, notes in ${notes.length} languages)`); @@ -106,6 +131,7 @@ export async function run(ctx: Ctx): Promise { throw new Error(`${(e as Error).message}\n\nThis app has never been published: the API may only create DRAFT releases. Re-run with --production draft, then send it from the Console (skill play-first-release).`); throw e; } + if (refused) return; // the ship-step contract: every track holds this release already (and no upload): nothing to do, exit 7 if (nothing && !a.has('dry')) shipNoop({ build: released || undefined, state: tracks.map(t => t.status).join(',') }); const prod = tracks.find(t => t.track === 'production'); @@ -116,6 +142,6 @@ export async function run(ctx: Ctx): Promise { } // machine-readable result for CI scripts (a Jenkins stage reads it instead of calling the Play API itself) if (a.has('json')) printJson({ versionCode: released ? Number(released) : null, sha256: aab ? createHash('sha256').update(readFileSync(aab)).digest('hex') : null, name: a.get('name') ?? null, - tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry') }); + tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry'), ...(seen.kind ? { releaseKind: seen.kind.kind } : {}) }); done('play', 'release done'); } diff --git a/cli/play/review-pending.ts b/cli/play/review-pending.ts index 2bb927d..a7fc809 100644 --- a/cli/play/review-pending.ts +++ b/cli/play/review-pending.ts @@ -25,6 +25,14 @@ export function isReviewRefused(err: unknown): boolean { return reasons.every(r => /badRequest|invalid|failedPrecondition|precondition/i.test(r)); } +/** The opposite 400: with managed publishing off Play refuses changesNotSentForReview=true (live: "Changes are sent for + * review automatically"); the commit is redone without it. "cannot be sent ... automatically" is the other refusal. */ +export function isAutoSendOnly(err: unknown): boolean { + const s = String(err instanceof Error ? err.message : err); + const m = /-> 400 (.*)$/s.exec(s); + return !!m && /are sent for review automatically/i.test(m[1]!); +} + /** The query string with changesNotSentForReview=true added to `base` ("?a=1" or ''). */ export function withNotSent(base: string | undefined, on: boolean): string { const parts = [(base ?? '').replace(/^\?/, ''), on ? 'changesNotSentForReview=true' : ''].filter(Boolean); diff --git a/cli/play/status.ts b/cli/play/status.ts index 74214cc..86b292a 100644 --- a/cli/play/status.ts +++ b/cli/play/status.ts @@ -11,6 +11,7 @@ import { playReleases, playReleaseLabel } from '../lib/portfolio'; import { playClient } from './client'; import { listOneTimeProducts } from './products'; import { readyLines, readyToPublish } from '../lib/play-publish'; +import { oldBuildLines, oldTrackBuilds } from './old-builds'; import { clearPending, pendingLine, publishingUrl, readPending } from './review-pending'; interface Track { track: string; releases?: { name?: string; versionCodes?: string[]; status: string; userFraction?: number }[] } @@ -33,7 +34,7 @@ export async function run(ctx: Ctx): Promise { // the App category is not in the API (edits.details has no category field): a console step const cat = playCategoryOf(config); const category = { config: cat?.category ?? null, source: cat?.source ?? null, api: false, plan: 'storebound console plan play-store-settings' }; - if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null, reviewPending, readyToPublish: readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }) }); return; } + if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null, reviewPending, oldBuilds: oldTrackBuilds(tracks), readyToPublish: readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }) }); return; } console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`); console.log(` category: ${cat ? `${cat.category} (${cat.source})` : 'not in the config (play.category)'} · not readable by the API: Store settings is a console step (${category.plan})`); console.log(` listings (${listings.length}): ${listings.map(l => l.language).join(', ') || '-'}`); @@ -44,6 +45,7 @@ export async function run(ctx: Ctx): Promise { console.log(` public store page: ${publicPage.state === 'public' ? `published (200), live version ${publicPage.versionName ?? (publicPage.varies ? 'varies with device' : 'not shown')}` : publicPage.state === 'notPublic' ? 'not public yet (404)' : `couldn't check it (${publicPage.error ?? 'not read'})`}${publicPage.stale ? ` (last known, ${publicPage.checkedAt.slice(0, 16).replace('T', ' ')} UTC)` : ''}`); for (const r of playReleases({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }).filter(x => x.track === 'production')) console.log(` production ${r.version ?? '?'} (${r.build ?? '?'}): ${playReleaseLabel(r)}`); for (const l of readyLines(readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }))) console.log(l); + for (const l of oldBuildLines(oldTrackBuilds(tracks))) console.log(l); if (pending) console.log(` review: ${pendingLine(pending)}`); console.log(` products (${products.length}):`); for (const p of products) console.log(` ${p.productId.padEnd(40)} ${(p.purchaseOptions ?? []).map(o => `${o.purchaseOptionId}:${o.state ?? '?'}`).join(' ')}`); diff --git a/cli/play/upload.ts b/cli/play/upload.ts index 0ccad8d..ed4e200 100644 --- a/cli/play/upload.ts +++ b/cli/play/upload.ts @@ -1,12 +1,14 @@ -// Upload an Android App Bundle to a Google Play testing track. -// storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] +// Upload an Android App Bundle to a Google Play track (testing tracks, or production for an UPDATE of a published app). +// storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--first-release] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] // Default AAB: the newest in build/android (storebound play build), or with --version-code N the one with that // versionCode. Package and versionCode are read from the bundle; the upload is refused when Play already has that // versionCode (exit 1), and under the ship-step contract (--json / --result-file, docs/ship-step.md) it is exit 7 // "nothing to do" with the track and state that are there: never a second upload. Release notes: the "What's new" of // every listing language. --dry is read-only (one throwaway edit: version codes and the track are read, the edit is // deleted). A real run asks first (--yes in scripts). --track production sends the release to Google's review (a -// pipeline's update step); --status draft keeps it for the Console. +// pipeline's update step): no owner gate when the production track already holds a published release (an update of +// a live app, cli/lib/release-kind.ts); a first-ever production release with --yes is exit 3 OWNER_STEP unless the owner +// approved it (--first-release, or bypass mode). --status draft keeps the release for the Console. import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import type { Ctx } from '../lib/ctx'; @@ -21,6 +23,7 @@ import { usedVersionCodes } from './next-version-code'; import { runPreCheck } from '../lib/pre-check'; import { shipNoop, shipPhase, shipReport, shipRun, shipWarn } from '../lib/ship-step'; import { consoleUrl, playTrackPage } from '../lib/console-links'; +import { enforceFirstRelease, playReleaseKind } from '../lib/release-kind'; const TAG = 'upload'; export const UPLOAD_TRACKS = ['internal', 'alpha', 'beta', 'production'] as const; @@ -83,11 +86,15 @@ export async function run(ctx: Ctx): Promise { if (shipRun()) { done(TAG, `versionCode ${info.versionCode} is on Google Play already: nothing uploaded`); return; } } if (used.includes(info.versionCode)) throw new Error(`versionCode ${info.versionCode} is already on Google Play: rebuild with --version-code ${nextVersionCode(used)} (storebound play build)`); - const summary = { aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10, + // production: update of a published app (no owner gate) or the first release (the owner's step) + const kind = track === 'production' && status !== 'draft' ? playReleaseKind(current) : null; + if (kind && !dry && !enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) return; + const summary = { ...(kind ? { releaseKind: kind.kind } : {}), aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10, track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`), ...(pre ? { preCheck: pre } : {}) }; if (dry) { if (json) { printJson(summary); return; } log(TAG, `${summary.aab}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}), ${summary.sizeMB} MB, versionCode free`); + if (kind) log(TAG, `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`); log(TAG, `would upload it and set track ${track} to ${status} ${info.versionCode}${notes.length ? `, notes in ${notes.length} language(s)` : ', no release notes'}${current.length ? ` (now on ${track}: ${summary.replaces.join('; ')})` : ''}`); done(TAG, 'upload planned (dry run, read-only)'); return; diff --git a/cli/reports.ts b/cli/reports.ts index 1032910..2cb88fb 100644 --- a/cli/reports.ts +++ b/cli/reports.ts @@ -3,6 +3,9 @@ // storebound reports apple-finance --month YYYY-MM [--detail] [--region ZZ] [--vendor N] // storebound reports play-earnings --month YYYY-MM // storebound reports play-sales --month YYYY-MM +// a day range in one JSON (per-day summary and totals per currency; days with no report listed in "missing", not an error): +// storebound reports apple-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (daily SALES reports) +// storebound reports play-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (per order-charged date and currency) // common: [--out .storebound/reports] [--csv ] [--dry] [--json] // The raw report is saved as it came (the TSV / the CSV out of the zip) next to a .summary.csv with only // aggregates (per product and currency; amounts are never added across currencies). --dry prints what would be @@ -14,15 +17,15 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { ascClient } from './apple/asc'; import { bucketName } from './lib/analytics'; -import { ascVendorNumber } from './lib/credentials'; +import { ascVendorNumber, playReportsBucket } from './lib/credentials'; import type { Ctx } from './lib/ctx'; import { need } from './lib/ctx'; import { toCsv } from './lib/csv'; import { STORAGE_READ_SCOPE } from './lib/google-auth'; import { done, log, printJson, warn } from './lib/log'; import { - APPLE_SALES_COMBOS, appleFinancePath, appleReportDate, appleSalesPath, decodeReport, parsePlayCsv, parseTsv, playReportPrefix, type SummaryRow, summarizeAppleFinance, - summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, summaryCsvRows, totalsByCurrency, + APPLE_SALES_COMBOS, appleFinancePath, appleReportDate, appleSalesPath, type DaySummary, decodeReport, monthsOfRange, parsePlayCsv, parseTsv, playReportPrefix, playSalesTotals, rangeCsvRows, rangeReport, + reportRange, type SummaryRow, summarizeAppleFinance, summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, summarizePlaySalesByDay, summaryCsvRows, totalsByCurrency, } from './lib/reports'; import { textTable } from './lib/table'; import { unzip } from './lib/zip'; @@ -30,7 +33,15 @@ import { googleClient } from './play/google'; import * as rt from './lib/rt'; const TAG = 'reports'; -const USAGE = 'usage: storebound reports apple-sales | apple-finance --month YYYY-MM | play-earnings --month YYYY-MM | play-sales --month YYYY-MM'; +const USAGE = 'usage: storebound reports apple-sales [--days N | --from YYYY-MM-DD [--to ...]] | apple-finance --month YYYY-MM | play-earnings --month YYYY-MM | play-sales --month YYYY-MM | play-sales --days N | --from YYYY-MM-DD [--to ...]'; + +/** Runs fn over items, at most `limit` at a time, results in item order. */ +async function pool(items: T[], limit: number, fn: (x: T) => Promise): Promise { + const out: R[] = new Array(items.length); + let next = 0; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]!); } })); + return out; +} function show(name: string, rows: SummaryRow[], json: boolean, extra: Record): void { const totals = totalsByCurrency(rows); @@ -57,6 +68,28 @@ export function appleReportHint(msg: string): string | null { return null; } +type Google = ReturnType; + +/** The CSV rows of a Play report month (all zips under the prefix, all CSVs in them); null when the bucket has none. */ +async function playMonth(g: Google, bucket: string, kind: 'earnings' | 'sales', month: string): Promise<{ rows: Record[]; text: string } | null> { + const prefix = playReportPrefix(kind, month); + const list = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o?prefix=${encodeURIComponent(prefix)}`; + const items = ((await g.json<{ items?: { name: string }[] }>('GET', list)).items ?? []).filter(o => o.name.endsWith('.zip')); + if (!items.length) return null; + const rows: Record[] = []; + let text = ''; + for (const o of items) { + log(TAG, `gs://${bucketName(bucket)}/${o.name}`); + const zip = await g.bytes(`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(o.name)}?alt=media`); + for (const e of unzip(zip).filter(x => x.name.toLowerCase().endsWith('.csv'))) { + const t = decodeReport(e.data); + text += (text ? '\n' : '') + t; + rows.push(...parsePlayCsv(t)); + } + } + return { rows, text }; +} + export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; @@ -82,9 +115,46 @@ export async function run(ctx: Ctx): Promise { path = appleFinancePath(vendor, month, { type, region: a.get('region') }); name = `apple-${type === 'FINANCIAL' ? 'finance' : 'finance-detail'}-${month}.tsv`; } + const range = sub === 'apple-sales' ? reportRange({ days: a.get('days'), from: a.get('from'), to: a.get('to') }) : null; + if (sub === 'apple-finance' && (a.has('days') || a.has('from') || a.has('to'))) throw new Error('--days / --from / --to are for apple-sales and play-sales (finance reports are monthly: --month)'); + if (range) { + const type = a.str('type', 'SALES').toUpperCase(); + if (type !== 'SALES') throw new Error('--days / --from / --to read the daily SALES reports: leave out --type (or SALES)'); + if (a.has('frequency') && a.str('frequency', 'DAILY').toUpperCase() !== 'DAILY') throw new Error('--days / --from / --to read DAILY reports'); + const pathOf = (date: string) => appleSalesPath(vendor, { type: 'SALES', frequency: 'DAILY', date }); + const shownRange = `GET ${pathOf('').replace(vendor, '')} for ${range.dates.length} day(s) ${range.from}..${range.to}`; + if (dry) { if (a.has('json')) printJson({ dryRun: true, request: shownRange, dir: out }); else log(TAG, `would ${shownRange} -> ${out}`); return; } + const c = await ascClient(root, config.apple?.bundleId ?? '', config.apple?.appId); + mkdirSync(out, { recursive: true }); + let rowCount = 0; + const got = await pool(range.dates, 4, async (date): Promise => { + let gz: Uint8Array | null; + try { gz = await c.bytes(pathOf(date)); } catch (e) { const hint = appleReportHint((e as Error).message); throw new Error(hint ? `${(e as Error).message} +${hint}` : (e as Error).message); } + if (!gz) return null; + const text = new TextDecoder().decode(gz[0] === 0x1f && gz[1] === 0x8b ? rt.gunzipSync(gz as Uint8Array) : gz); + writeFileSync(join(out, `apple-sales-daily-${date}.tsv`), text); + const rows = parseTsv(text); + rowCount += rows.length; + const summary = summarizeAppleSales(rows); + return { date, rows: rows.length, summary, totals: totalsByCurrency(summary) }; + }); + const rep = rangeReport(got.filter((x): x is DaySummary => !!x), range.dates); + const summaryFile = csvOut ?? join(out, `apple-sales-daily-${range.from}_${range.to}.summary.csv`); + mkdirSync(dirname(summaryFile), { recursive: true }); + writeFileSync(summaryFile, toCsv(rangeCsvRows(rep.days))); + if (rep.missing.length) warn(TAG, `no report for ${rep.missing.join(', ')} (no sales then, or not published yet: daily reports come out the next day)`); + if (a.has('json')) printJson({ report: 'apple-sales', type: 'SALES', frequency: 'DAILY', from: range.from, to: range.to, rows: rowCount, days: rep.days, totals: rep.totals, missing: rep.missing, files: { dir: out, summary: summaryFile } }); + else { + console.log(rep.days.length ? textTable(['date', 'currency', 'units', 'amount'], rep.days.flatMap(d => d.totals.map(t => [d.date, t.currency, t.units, t.amount.toFixed(2)]))) : 'no rows'); + if (rep.totals.length) console.log('\n' + textTable(['currency', 'units', 'total'], rep.totals.map(t => [t.currency, t.units, t.amount.toFixed(2)]))); + } + done(TAG, `${rep.days.length}/${range.dates.length} day(s), ${rowCount} rows -> ${out}; summary ${summaryFile}`); + return; + } const shown = path.replace(vendor, ''); if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET ${shown}`, file: join(out, name) }); else log(TAG, `would GET ${shown} -> ${join(out, name)}`); return; } - const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); + const c = await ascClient(root, config.apple?.bundleId ?? '', config.apple?.appId); log(TAG, `GET ${shown}`); let gz: Uint8Array | null; try { gz = await c.bytes(path); } catch (e) { const hint = appleReportHint((e as Error).message); throw new Error(hint ? `${(e as Error).message}\n${hint}` : (e as Error).message); } @@ -100,27 +170,51 @@ export async function run(ctx: Ctx): Promise { } if (sub === 'play-earnings' || sub === 'play-sales') { - const bucket = config.play?.reportsBucket; - if (!bucket) throw new Error(`play.reportsBucket is not set (${consoleLink('play.reports-financial', '> Copy Cloud Storage URI')})`); - const month = need(a.get('month'), '--month YYYY-MM'); + const bucket = playReportsBucket(root, config.play?.reportsBucket); + if (!bucket) throw new Error(`play.reportsBucket is not set (env PLAY_REPORTS_BUCKET or the credential profile's reportsBucket also work; ${consoleLink('play.reports-financial', '> Copy Cloud Storage URI')})`); const kind = sub === 'play-earnings' ? 'earnings' : 'sales'; + const range = reportRange({ days: a.get('days'), from: a.get('from'), to: a.get('to') }); + if (range && kind === 'earnings') throw new Error('play-earnings is monthly (--month YYYY-MM); the daily revenue is play-sales --days N'); + if (range) { + const months = monthsOfRange(range.from, range.to); + const where = months.map(m => `gs://${bucketName(bucket)}/${playReportPrefix('sales', m)}*.zip`); + if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET ${where.join(' ')}`, from: range.from, to: range.to, dir: out }); else log(TAG, `would read ${where.join(', ')} -> ${out}`); return; } + const g = googleClient(root, STORAGE_READ_SCOPE); + const all: Record[] = []; + const missingMonths: string[] = []; + const files: string[] = []; + mkdirSync(out, { recursive: true }); + for (const m of months) { + const r = await playMonth(g, bucket, 'sales', m); + if (!r) { missingMonths.push(m); continue; } + all.push(...r.rows); + const file = join(out, `play-sales-${m}.csv`); + writeFileSync(file, r.text); + files.push(file); + } + const days = summarizePlaySalesByDay(all, range.from, range.to); + const have = new Set(days.map(d => d.date)); + const emptyDays = range.dates.filter(d => !have.has(d)); + const summaryFile = csvOut ?? join(out, `play-sales-daily-${range.from}_${range.to}.summary.csv`); + mkdirSync(dirname(summaryFile), { recursive: true }); + writeFileSync(summaryFile, toCsv([['date', 'currency', 'orders', 'refunds', 'item_price', 'taxes', 'charged', 'refunded', 'net'], ...days.map(d => [d.date, d.currency, d.orders, d.refunds, d.itemPrice, d.taxes, d.charged, d.refunded, d.net])])); + const totals = playSalesTotals(days); + if (missingMonths.length) warn(TAG, `no sales report for month(s) ${missingMonths.join(', ')} in the bucket (not published yet, or the service account lacks "View financial data")`); + if (a.has('json')) printJson({ report: 'play-sales', from: range.from, to: range.to, rows: all.length, days, totals, emptyDays, missingMonths, estimated: true, files: { raw: files, summary: summaryFile } }); + else { + console.log(days.length ? textTable(['date', 'currency', 'orders', 'refunds', 'charged', 'refunded', 'net'], days.map(d => [d.date, d.currency, d.orders, d.refunds, d.charged.toFixed(2), d.refunded.toFixed(2), d.net.toFixed(2)])) : 'no orders in the range'); + if (totals.length) console.log('\n' + textTable(['currency', 'orders', 'refunds', 'charged', 'refunded', 'net'], totals.map(t => [t.currency, t.orders, t.refunds, t.charged.toFixed(2), t.refunded.toFixed(2), t.net.toFixed(2)]))); + } + done(TAG, `${days.length} order day line(s) of ${range.dates.length} day(s) -> ${summaryFile}`); + return; + } + const month = need(a.get('month'), '--month YYYY-MM (or --days N / --from YYYY-MM-DD for play-sales)'); const prefix = playReportPrefix(kind, month); - const list = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o?prefix=${encodeURIComponent(prefix)}`; if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET gs://${bucketName(bucket)}/${prefix}*.zip`, dir: out }); else log(TAG, `would read gs://${bucketName(bucket)}/${prefix}*.zip -> ${out}`); return; } const g = googleClient(root, STORAGE_READ_SCOPE); - const items = ((await g.json<{ items?: { name: string }[] }>('GET', list)).items ?? []).filter(o => o.name.endsWith('.zip')); - if (!items.length) { done(TAG, `no ${prefix}*.zip in the bucket (not published yet: earnings come early in the next month; or the service account lacks "View financial data")`); if (a.has('json')) printJson({ report: kind, month, empty: true }); return; } - const all: Record[] = []; - let text = ''; - for (const o of items) { - log(TAG, `gs://${bucketName(bucket)}/${o.name}`); - const zip = await g.bytes(`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(o.name)}?alt=media`); - for (const e of unzip(zip).filter(x => x.name.toLowerCase().endsWith('.csv'))) { - const t = decodeReport(e.data); - text += (text ? '\n' : '') + t; - all.push(...parsePlayCsv(t)); - } - } + const month1 = await playMonth(g, bucket, kind, month); + if (!month1) { done(TAG, `no ${prefix}*.zip in the bucket (not published yet: earnings come early in the next month; or the service account lacks "View financial data")`); if (a.has('json')) printJson({ report: kind, month, empty: true }); return; } + const all = month1.rows, text = month1.text; if (!all.length) warn(TAG, 'the zip(s) held no CSV rows'); const summary = kind === 'earnings' ? summarizePlayEarnings(all) : summarizePlaySales(all); const files = save(out, `play-${kind}-${month}.csv`, text, summary, csvOut); diff --git a/cli/serve.ts b/cli/serve.ts index 9b81220..6407728 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -28,6 +28,7 @@ import { androidSdk, detectTools } from './lib/tools'; import { compiled, pickCallEnv, runCommand, type RunOptions, type Running } from './lib/runner'; import { inputSchema, notSentProp, type ToolDef, TOOLS, toArgv, validateInput } from './mcp-tools'; import * as rt from './lib/rt'; +import { ensureCliShim } from './lib/cli-shim'; type Id = string | number; interface Msg { jsonrpc?: string; id?: Id | null; method?: string; params?: unknown } @@ -151,7 +152,7 @@ export const DESKTOP_ONLY: ToolDef[] = [ version_name: { type: 'string', pattern: '^[0-9A-Za-z.+-]{1,40}$', description: 'versionName (default: from the project)' } }, args: i => [...opt('flavor', i.flavor), ...opt('module', i.module), ...opt('version-name', i.version_name)] }, { name: 'play_upload', title: 'Upload an AAB to a testing track', command: ['play', 'upload'], write: true, network: true, - description: 'Uploads the newest AAB of the build output to a Google Play testing track (internal, closed or open testing), versionCode checked first. Testers of that track get it; production stays an owner action.', + description: 'Uploads the newest AAB of the build output to a Google Play testing track (internal, closed or open testing), versionCode checked first. Testers of that track get it. Production: an update of a published app is `storebound play upload --track production --yes` in a terminal or pipeline (no owner step); the first-ever production release stays the owner\'s.', props: { track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, status: { type: 'string', enum: ['completed', 'draft'], default: 'completed' }, name: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,50}$', description: 'Release name (default: the version)' } }, args: i => [...opt('track', i.track), ...opt('status', i.status), ...opt('name', i.name)] }, @@ -613,6 +614,8 @@ export async function serveStdio(): Promise { const send: Send = o => { process.stdout.write(JSON.stringify(o) + '\n'); }; const session = new Session(send); console.error(`[storebound serve] ${pkg.version} on stdio (${DESKTOP_PROTOCOL}), waiting for session.hello`); + // the app's own CLI on the terminal PATH (lib/cli-shim.ts); only the bundled sidecar, after the handshake had its turn + if (compiled()) setTimeout(() => { for (const l of ensureCliShim()) console.error(`[storebound serve] ${l}`); }, 2000); let buf = ''; const decoder = new TextDecoder(); const reader = rt.stdinStream().getReader(); diff --git a/cli/shots/browser.ts b/cli/shots/browser.ts index e009c05..bd33563 100644 --- a/cli/shots/browser.ts +++ b/cli/shots/browser.ts @@ -30,24 +30,30 @@ import * as rt from '../lib/rt'; class PlaywrightShapeError extends Error {} async function chromium(root: string): Promise { + // why each place failed, for the error: a swallowed import error read as "not found" (a Mac run had it installed) + const tried: string[] = []; for (const base of [root, rt.packageRoot()]) { + let p = ''; try { - const p = rt.resolveFrom('playwright', base); + p = rt.resolveFrom('playwright', base); // only an installed copy (node_modules): outside a project Bun auto-installs the newest playwright into its global // cache (~/.bun/install/cache), whose browser build was never downloaded ("headless shell: not installed" on // Linux CI, where no Google Chrome hides it); storebound's own copy is next - if (!isInstalledPackage(p)) continue; + if (!isInstalledPackage(p)) { tried.push(`${p}: not an installed copy (Bun's global cache)`); continue; } // playwright's entry is CommonJS: depending on the runtime and version the ESM import puts chromium on the // namespace or only under default (a project's playwright 1.57 under a global Bun install gave undefined) const m = await rt.importPath(p); const c = m?.chromium ?? m?.default?.chromium; if (!c || typeof c.launch !== 'function') throw new PlaywrightShapeError(`playwright at ${p} has no chromium.launch (exports: ${Object.keys(m ?? {}).slice(0, 8).join(', ') || 'none'}${m?.default ? `; default: ${Object.keys(m.default).slice(0, 8).join(', ')}` : ''})`); return c; - } catch (e) { if (e instanceof PlaywrightShapeError) throw e; /* next */ } + } catch (e) { + if (e instanceof PlaywrightShapeError) throw e; + tried.push(p ? `${p}: ${(e as Error).message.split('\n')[0]}` : `not resolvable from ${base}`); + } } // only reached in the compiled sidecar: the bundled playwright-core (not a runtime dependency of the npm package) if (compiled()) return (await import('playwright-core')).chromium; - throw new Error('playwright not found: in your project run `bun add -d playwright` (and `bunx playwright install chromium` if Google Chrome is not installed)'); + throw new Error(`playwright not found: in your project run \`bun add -d playwright\` (and \`bunx playwright install chromium\` if Google Chrome is not installed)\n ${tried.join('\n ')}`); } /** A resolved module path inside a node_modules folder (not one of Bun's auto-installs from its global cache). */ diff --git a/cli/shots/capture-web.ts b/cli/shots/capture-web.ts index 0229eeb..a2e4ff5 100644 --- a/cli/shots/capture-web.ts +++ b/cli/shots/capture-web.ts @@ -23,11 +23,15 @@ import { join } from 'node:path'; import type { ShotSpec, ShotsCapture } from '../lib/config'; import { log, warn } from '../lib/log'; import { type AuthPlan, expiredHint, landedOnLogin, maskSecrets, safeUrl } from './auth'; -import { shotsFor } from './devices'; +import { type CaptureDevice, shotsFor } from './devices'; import { applySelectors, candidates, probe } from './selectors'; -import type { FrameDims } from './lib'; +import { DEFAULT_LANDSCAPE, type FrameDims } from './lib'; -type Device = 'phone' | 'tablet'; +/** Viewport per device class; landscape defaults to shots.landscape.size's default when a caller passes only phone / tablet. */ +export type CaptureDims = Record<'phone' | 'tablet', FrameDims> & { landscape?: FrameDims }; +const dimsOf = (dims: CaptureDims, device: Device): FrameDims => dims[device] ?? DEFAULT_LANDSCAPE; + +type Device = CaptureDevice; export const STEPS = ['goto', 'waitFor', 'eval', 'delay', 'screenshot'] as const; export type Step = typeof STEPS[number]; export type Timing = Record; @@ -100,14 +104,14 @@ export async function hashServed(baseUrl: string): Promise { } /** Every web frame to capture, in a fixed order (lang, device, shot), with its URL and its input hash. */ -export function planWeb(cap: ShotsCapture, o: { langs: string[]; devices: Device[]; only: string[]; dims: Record; framesDir: string; - build: string; copyText?: (lang: string) => string; fonts?: string }): WebJob[] { +export function planWeb(cap: ShotsCapture, o: { langs: string[]; devices: Device[]; only: string[]; dims: CaptureDims; framesDir: string; + build: string; copyText?: (lang: string) => string; fonts?: string; allLandscape?: boolean }): WebJob[] { const jobs: WebJob[] = []; - for (const lang of o.langs) for (const device of o.devices) for (const [name, spec] of shotsFor(cap, device, o.only)) { + for (const lang of o.langs) for (const device of o.devices) for (const [name, spec] of shotsFor(cap, device, o.only, o.allLandscape)) { const q = (cap.query ?? '').replaceAll('{lang}', lang); const path = (spec.path ?? '/').replaceAll('{lang}', lang) + (q ? ((spec.path ?? '/').includes('?') ? '&' : '?') + q : ''); const url = (cap.baseUrl ?? '').replace(/\/$/, '') + path; - const hash = sha(JSON.stringify({ spec, url, dims: o.dims[device], session: cap.session ?? 'per-shot', build: o.build, copy: o.copyText?.(lang) ?? '', fonts: o.fonts ?? '' })); + const hash = sha(JSON.stringify({ spec, url, dims: dimsOf(o.dims, device), session: cap.session ?? 'per-shot', build: o.build, copy: o.copyText?.(lang) ?? '', fonts: o.fonts ?? '' })); const frame = `${lang}/${device}/${name}.png`; jobs.push({ lang, device, name, spec, url, path, out: join(o.framesDir, frame), frame, hash }); } @@ -137,7 +141,7 @@ export interface CaptureReport { /* eslint-disable @typescript-eslint/no-explicit-any */ /** Captures the jobs in a browser: per-shot contexts, or one page per language + device in per-language sessions. */ -export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[], o: { concurrency: number; dims: Record; auth?: AuthPlan }): Promise { +export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[], o: { concurrency: number; dims: CaptureDims; auth?: AuthPlan }): Promise { const auth = o.auth; const mask = (t: string): string => (auth ? maskSecrets(t, auth.secrets) : t); const expired = new Set(); @@ -147,8 +151,8 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[] let finished = 0; const total = jobs.length; const newPage = async (j: WebJob): Promise<{ c: any; page: any; errs: string[] }> => { - const d = o.dims[j.device]; - const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang, ...(auth?.contextOptions ?? {}) }); + const d = dimsOf(o.dims, j.device); + const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: j.device !== 'landscape', isMobile: j.device !== 'landscape', serviceWorkers: 'block', locale: j.lang, ...(auth?.contextOptions ?? {}) }); // landscape = a desktop window: no touch, no mobile emulation // sign in once per context, cookies kept in it (request auth); a failed sign-in stops the whole capture if (auth) try { await auth.prepare(c); } catch (e) { await c.close(); throw Object.assign(new Error(mask((e as Error).message)), { authFailed: true }); } const page = await c.newPage(); @@ -167,7 +171,7 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[] if (!expired.has(safeUrl(s.page.url()))) { expired.add(safeUrl(s.page.url())); warn('shots', `${j.lang}/${j.device}/${j.name}: ${mask(expiredHint(s.page.url()))}`); } throw new Error(`landed on the sign-in page ${safeUrl(s.page.url())}`); } - await applySelectors(s.page, j.spec, { device: j.device, w: o.dims[j.device].w, h: o.dims[j.device].h }, waitTimeout); // waitSelector, click: a miss names the selector and the size + await applySelectors(s.page, j.spec, { device: j.device, w: dimsOf(o.dims, j.device).w, h: dimsOf(o.dims, j.device).h }, waitTimeout); // waitSelector, click: a miss names the selector and the size if (j.spec.waitFor) await s.page.waitForFunction(j.spec.waitFor, null, { timeout: waitTimeout, polling: 50 }); t.waitFor = Date.now() - t0; t0 = Date.now(); for (const js of j.spec.eval ?? []) await s.page.evaluate(js); @@ -251,14 +255,14 @@ export function writeCaptureState(root: string, report: CaptureReport, cache: Ca /* eslint-disable @typescript-eslint/no-explicit-any */ /** `shots capture --dry --probe`: opens each page (no screenshot), runs the shot's waitFor, and reports per size which * selector candidates match. Returns the log lines; `missed` counts shots where no candidate of a list matched. */ -export async function probeSelectors(browser: any, jobs: WebJob[], o: { dims: Record; auth?: AuthPlan; timeoutMs?: number }): Promise<{ lines: string[]; missed: number }> { +export async function probeSelectors(browser: any, jobs: WebJob[], o: { dims: CaptureDims; auth?: AuthPlan; timeoutMs?: number }): Promise<{ lines: string[]; missed: number }> { const lines: string[] = []; let missed = 0; for (const j of jobs) { const lists = [['waitSelector', candidates(j.spec.waitSelector, j.device)], ...(Array.isArray(j.spec.click) && j.spec.click.some(c => typeof c !== 'string') ? j.spec.click : j.spec.click === undefined ? [] : [j.spec.click]) .map((c, i) => [`click[${i}]`, candidates(c as any, j.device)] as const)].filter(([, l]) => l.length) as [string, string[]][]; if (!lists.length) continue; - const d = o.dims[j.device]; - const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: 1, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang, ...(o.auth?.contextOptions ?? {}) }); + const d = dimsOf(o.dims, j.device); + const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: 1, hasTouch: j.device !== 'landscape', isMobile: j.device !== 'landscape', serviceWorkers: 'block', locale: j.lang, ...(o.auth?.contextOptions ?? {}) }); try { if (o.auth) await o.auth.prepare(c); const page = await c.newPage(); diff --git a/cli/shots/capture.ts b/cli/shots/capture.ts index c5b6d24..b0cedc4 100644 --- a/cli/shots/capture.ts +++ b/cli/shots/capture.ts @@ -1,9 +1,15 @@ // Raw frames from a running build of your app or game, from any engine. -// storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder] +// storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet|landscape] [--source web|ios-sim|android-emu|folder] // [--dry [--probe]] [--keep-booted] [--concurrency N] [--only-missing | --force] // Frames go to ///.png: the screen BELOW the status bar (the template draws one). +// Devices: phone, tablet and landscape (/landscape/.png, web source only: Mac App Store and Chromebook +// screenshots; a desktop window, no status bar crop). The default device list adds landscape when the project renders a +// landscape size (apple.mac, play.chromebook, shots.landscape.playTablets); then shots with no device (and 'both') are +// captured for landscape too, since render needs a landscape frame per scene. A shot says device: 'landscape' to be +// landscape only, 'phone' / 'tablet' to stay out of it. ios-sim / android-emu skip landscape with a warning. // shots.capture.source (default web): -// web headless Chromium (playwright), mobile viewport + touch at phone / tablet size. Each shot: +// web headless Chromium (playwright), mobile viewport + touch at phone / tablet size, or a desktop viewport (no touch) +// at shots.landscape.size (default 1440 x 900 @2x) for landscape. Each shot: // { path, waitFor?: JS expression that turns true, eval?: [JS], delayMs?, device? }; URL = baseUrl + path + // query, "{lang}" replaced. Parallel contexts, per-language page reuse, the input-hash cache and the // per-frame timing (goto / waitFor / eval / delay / screenshot): capture-web.ts. Signed-in apps: @@ -36,15 +42,16 @@ import { done, log, step, warn } from '../lib/log'; import { closeBrowser, launch } from './browser'; import { checkFrameFolder } from './check'; import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, adbInstallArgs, adbScreencapArgs, adbStartArgs, adbStateArgs, adbTarget, - cropArgs, cropTopFor, fill, pickSimulator, shotsFor, simBootArgs, simBootStatusArgs, simInstallArgs, simLaunchArgs, simOpenUrlArgs, + cropArgs, cropTopFor, fill, CAPTURE_DEVICES, type CaptureDevice, pickSimulator, shotsFor, simBootArgs, simBootStatusArgs, simInstallArgs, simLaunchArgs, simOpenUrlArgs, simScreenshotArgs, simShutdownArgs, simStatusBarArgs, simStatusBarClearArgs, simTarget, SOURCES, type Source, TOOL_HINTS, validateCapture } from './devices'; -import { type CopyFile, copyText, type FrameDims, frameDims } from './lib'; +import { type CopyFile, copyText, type FrameDims, frameDims, landscapeDims, rendersLandscape } from './lib'; import { captureWeb, defaultConcurrency, probeSelectors, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web'; import { imageInfo } from './spec'; import { authPlan, safeUrl } from './auth'; import * as rt from '../lib/rt'; -type Device = 'phone' | 'tablet'; +type Device = CaptureDevice; +type PhoneTablet = 'phone' | 'tablet'; export function sh(cmd: string, args: string[], o: { allowFail?: boolean; timeout?: number } = {}): { out: Buffer; err: string; code: number } { const r = spawnSync(cmd, args, { timeout: o.timeout ?? 120_000, maxBuffer: 256 * 1024 * 1024 }); @@ -75,7 +82,7 @@ export function localeOf(config: ShipConfig, lang: string, source: Source): stri } /** Crop the status bar off a full-screen capture and store it as a 24-bit RGB frame. */ -function storeFrame(tmp: string, out: string, cropTop: ShotsCapture['cropTop'], device: Device, dims: FrameDims): void { +function storeFrame(tmp: string, out: string, cropTop: ShotsCapture['cropTop'], device: PhoneTablet, dims: FrameDims): void { const { w, h } = imageInfo(readFileSync(tmp)); if (!h) throw new Error('screenshot is not a PNG'); const top = cropTopFor(cropTop, device, h, dims); @@ -98,13 +105,20 @@ export async function run(ctx: Ctx): Promise { if (problems.length) throw new Error(`shots.capture:\n ${problems.join('\n ')}`); const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id); const only = a.list('only'); - const devices: Device[] = a.get('device') ? [a.get('device') as Device] : ['phone', 'tablet']; - if (devices.some(d => d !== 'phone' && d !== 'tablet')) throw new Error('--device: phone | tablet'); + if (a.get('device') && !CAPTURE_DEVICES.includes(a.get('device') as Device)) throw new Error(`--device: ${CAPTURE_DEVICES.join(' | ')}`); + const landscapeOn = rendersLandscape(config); + let devices: Device[] = a.get('device') ? [a.get('device') as Device] : ['phone', 'tablet', ...(landscapeOn ? ['landscape' as const] : [])]; + const allLandscape = landscapeOn || a.get('device') === 'landscape'; // shots with no device / 'both' also make a landscape frame + if (source !== 'web' && source !== 'folder' && devices.includes('landscape')) { + warn('shots', `landscape frames (Mac, Chromebook) are not supported from ${source}: skipped; capture them from a web build (--source web) or put them in //landscape/ (--source folder)`); + devices = devices.filter(d => d !== 'landscape'); + } + const mobile = devices.filter((d): d is PhoneTablet => d !== 'landscape'); const framesDir = resolve(shots.frames); - const dims = frameDims(config); + const dims = { ...frameDims(config), landscape: landscapeDims(config) }; const tmpDir = join(root, '.storebound', 'tmp'); const target = (lang: string, device: Device, name: string) => { const d = join(framesDir, lang, device); if (!dry) mkdirSync(d, { recursive: true }); return join(d, `${name}.png`); }; - const total = langs.length * devices.reduce((t, d) => t + shotsFor(cap, d, only).length, 0); + const total = langs.length * devices.reduce((t, d) => t + shotsFor(cap, d, only, allLandscape).length, 0); let failed = 0, n = 0, i = 0; const progress = (what: string) => log('shots', step(++i, total, what)); /** Runs a simctl / adb step, or prints it with --dry. */ @@ -125,7 +139,7 @@ export async function run(ctx: Ctx): Promise { if (source === 'web') { if (dry) { - for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) { + for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only, allLandscape)) { progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`); const sel: [string, string[]][] = [['waitSelector', candidates(s.waitSelector, device)], ...(Array.isArray(s.click) && s.click.some(c => typeof c !== 'string') ? s.click : s.click === undefined ? [] : [s.click]).map((c, k) => [`click[${k}]`, candidates(c as ShotSelector, device)] as [string, string[]])]; for (const [what, list] of sel) if (list.length) log('shots', ` ${what} on ${device} ${dims[device].w}x${dims[device].h}: ${list.join(' | ')}`); @@ -133,7 +147,7 @@ export async function run(ctx: Ctx): Promise { if (a.has('probe')) { // open the pages and test every candidate on each size (needs the app running at baseUrl) need(cap.baseUrl, 'shots.capture.baseUrl'); const auth = cap.auth ? authPlan(cap.auth, { root, baseUrl: cap.baseUrl! }) : undefined; - const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build: '', fonts: '' }); + const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build: '', fonts: '', allLandscape }); const browser = await launch(root, { headless: true, args: ['--mute-audio'] }); let r; try { r = await probeSelectors(browser, jobs, { dims, ...(auth ? { auth } : {}) }); } finally { await closeBrowser(browser); } @@ -152,7 +166,7 @@ export async function run(ctx: Ctx): Promise { let copy: CopyFile = {}; try { copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile; } catch { /* no copy yet */ } const fontsCss = join(root, '.storebound', 'fonts', 'fonts.css'); - const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build, copyText: l => copyText(l, copy[l]), + const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build, allLandscape, copyText: l => copyText(l, copy[l]), fonts: existsSync(fontsCss) ? readFileSync(fontsCss, 'utf8') : '' }); const cache = readCache(root); const { run: todo, skipped } = selectJobs(jobs, cache, a.has('force') ? 'force' : a.has('only-missing') ? 'only-missing' : 'changed'); @@ -174,7 +188,7 @@ export async function run(ctx: Ctx): Promise { const ios = need(cap.ios, 'shots.capture.ios'); if (!dry) { requireTool('xcrun'); requireTool('ffmpeg'); } const list = dry && !rt.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString(); - for (const device of devices) { + for (const device of mobile) { const want = simTarget(ios, device); const todo = shotsFor(cap, device, only); if (!todo.length) continue; @@ -222,7 +236,7 @@ export async function run(ctx: Ctx): Promise { const and = need(cap.android, 'shots.capture.android'); const adb = adbPath(); if (!dry) { requireTool(adb, 'adb'); requireTool('ffmpeg'); } - for (const device of devices) { + for (const device of mobile) { const todo = shotsFor(cap, device, only); if (!todo.length) continue; const serial = adbTarget(and, device); diff --git a/cli/shots/devices.ts b/cli/shots/devices.ts index 1fa9f20..3691f22 100644 --- a/cli/shots/devices.ts +++ b/cli/shots/devices.ts @@ -43,7 +43,7 @@ export function validateCapture(c: ShotsCapture | undefined): string[] { if (s.click !== undefined) for (const [i, c] of (Array.isArray(s.click) && s.click.some(x => typeof x !== 'string') ? s.click : [s.click]).entries()) e.push(...validateSelector(`shots.capture.shots.${name}.click[${i}]`, c)); } else if (s.waitSelector !== undefined || s.click !== undefined) e.push(`shots.capture.shots.${name}: waitSelector / click only apply to source web`); if (s.navigate && !['spa', 'reload'].includes(s.navigate)) e.push(`shots.capture.shots.${name}.navigate: spa | reload`); - if (s.device && !['phone', 'tablet', 'both'].includes(s.device)) e.push(`shots.capture.shots.${name}.device: phone | tablet | both`); + if (s.device && !['phone', 'tablet', 'both', 'landscape'].includes(s.device)) e.push(`shots.capture.shots.${name}.device: phone | tablet | both | landscape`); if (src === 'web' && s.path === undefined) e.push(`shots.capture.shots.${name}.path: URL path (source web)`); if ((src === 'web' || src === 'folder') && (s.args || s.extras)) e.push(`shots.capture.shots.${name}: args / extras only apply to ios-sim / android-emu`); for (const [k, v] of Object.entries(s.extras ?? {})) { @@ -96,9 +96,20 @@ export function adbTarget(and: NonNullable, device: 'ph return device === 'tablet' ? and.serials?.tablet : and.serials?.phone ?? and.serial; } -/** Which shots run on which device class. */ -export function shotsFor(c: ShotsCapture, device: 'phone' | 'tablet', only: string[] = []): [string, ShotSpec][] { - return Object.entries(c.shots ?? {}).filter(([name, s]) => (!only.length || only.includes(name)) && ((s.device ?? 'phone') === 'both' || (s.device ?? 'phone') === device)); +/** Capture device classes: phone / tablet frames (status bar cut) and desktop landscape frames (Mac, Chromebook). */ +export type CaptureDevice = 'phone' | 'tablet' | 'landscape'; +export const CAPTURE_DEVICES: readonly CaptureDevice[] = ['phone', 'tablet', 'landscape']; + +/** Which shots run on which device class. phone / tablet: device unset = phone, 'both' = phone + tablet (unchanged). + * landscape: the shots with device 'landscape'; with `allLandscape` (the project renders a landscape size, or + * --device landscape was asked for) also every shot with no device or 'both', because render needs a landscape frame + * for each scene. A shot that says 'phone' or 'tablet' explicitly stays off the landscape list. */ +export function shotsFor(c: ShotsCapture, device: CaptureDevice, only: string[] = [], allLandscape = false): [string, ShotSpec][] { + return Object.entries(c.shots ?? {}).filter(([name, s]) => { + if (only.length && !only.includes(name)) return false; + if (device === 'landscape') return s.device === 'landscape' || (allLandscape && (s.device === undefined || s.device === 'both')); + return s.device !== 'landscape' && ((s.device ?? 'phone') === 'both' || (s.device ?? 'phone') === device); + }); } // ---- iOS Simulator (xcrun simctl) ---- diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index f20964c..3087424 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -175,6 +175,15 @@ export function frameDims(config: Pick): Record<' return { phone: { ...DEFAULT_PHONE, ...config.shots?.phone }, tablet: { ...tablet, ...config.shots?.tablet } }; } +/** Landscape capture viewport (shots.landscape.size): a desktop window, 1440 x 900 CSS px @2x = 2880 x 1800 (the Mac size). No status bar. */ +export const DEFAULT_LANDSCAPE: FrameDims = { w: 1440, h: 900, dpr: 2, bar: 0 }; + +/** Viewport `shots capture` uses for landscape frames (/landscape/.png): shots.landscape.size over the default. */ +export function landscapeDims(config: Pick): FrameDims { + const s = config.shots?.landscape?.size; + return { ...DEFAULT_LANDSCAPE, ...(s ? { w: s.w, h: s.h, ...(s.dpr ? { dpr: s.dpr } : {}) } : {}), bar: 0 }; +} + /** Play tablet size keys (7" and 10"): optional on Play, switched by play.tablets. */ export const PLAY_TABLET_SIZES: readonly string[] = ['tab7', 'tab10']; @@ -193,6 +202,12 @@ export function defaultSizes(config: Pick, tablets : k.startsWith('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play)); } +/** True when `shots render` draws a landscape size for this project (apple.mac, play.chromebook, shots.landscape.playTablets): + * it then needs /landscape/.png frames, which `shots capture` makes with the landscape device. Same rules as render. */ +export function rendersLandscape(config: Pick): boolean { + return defaultSizes(config).some(s => renderDims(s, config.shots).landscape); +} + /** Scenes of a variant (its order / subset, palette overrides), or the base scenes. */ export function variantScenes(shots: ShotsConfig, variant?: string): ShotScene[] { if (!variant) return shots.scenes; diff --git a/cli/shots/selectors.ts b/cli/shots/selectors.ts index 1efb778..7c84b60 100644 --- a/cli/shots/selectors.ts +++ b/cli/shots/selectors.ts @@ -4,7 +4,7 @@ // a list on the phone and a grid at 1180x820 needs different selectors; the miss message names the selector and the size. import type { ShotSelector } from '../lib/config'; -type Device = 'phone' | 'tablet'; +import type { CaptureDevice as Device } from './devices'; /** Candidates for one device: its own list first, then the shared ones, without repeats. */ export function candidates(sel: ShotSelector | undefined, device: Device): string[] { @@ -19,12 +19,12 @@ export function validateSelector(where: string, sel: unknown): string[] { const okList = (v: unknown): boolean => ok(v) || (Array.isArray(v) && v.length > 0 && v.every(ok)); if (okList(sel)) return []; if (sel && typeof sel === 'object' && !Array.isArray(sel)) { - const o = sel as Record, bad = Object.keys(o).filter(k => !['phone', 'tablet', 'any'].includes(k)); - if (bad.length) return [`${where}: unknown key ${bad.join(', ')} (phone | tablet | any)`]; - if (!Object.keys(o).length) return [`${where}: at least one of phone | tablet | any`]; + const o = sel as Record, bad = Object.keys(o).filter(k => !['phone', 'tablet', 'landscape', 'any'].includes(k)); + if (bad.length) return [`${where}: unknown key ${bad.join(', ')} (phone | tablet | landscape | any)`]; + if (!Object.keys(o).length) return [`${where}: at least one of phone | tablet | landscape | any`]; return Object.entries(o).filter(([, v]) => !okList(v)).map(([k]) => `${where}.${k}: a selector or a list of selectors`); } - return [`${where}: a selector, a list of selectors, or { phone, tablet, any }`]; + return [`${where}: a selector, a list of selectors, or { phone, tablet, landscape, any }`]; } /** Minimal Playwright surface used here, so tests can fake a page. */ diff --git a/cli/shots/spec.ts b/cli/shots/spec.ts index 40ae395..bd67387 100644 --- a/cli/shots/spec.ts +++ b/cli/shots/spec.ts @@ -61,11 +61,10 @@ export const IMAGE_SPECS: Record = { feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false }, }; -/** ASC screenshotDisplayType per iPhone Duo size key. null = NOT IN THE API YET: ScreenshotDisplayType in App Store - * Connect API 4.5.1 (spec re-checked 2026-10-20: the published zip is still the 2026-10-06 one, same sha256) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO). - * When Apple adds it: regenerate (bun tools/gen-api.ts) and put the exact enum names here; uploads then include them. */ +/** iPhone Duo size keys. Values stay null: ScreenshotDisplayType has no Duo value and never will (API 4.5.1 put the + * display class IPHONE_DUO into the App Asset Library only and deprecated screenshot sets); apple screenshots + * uploads Duo through the library (cli/apple/duo-library.ts, pitfall 130). */ export const IPHONE_DUO_DISPLAY: Record = { 'iphone-duo-outer': null, 'iphone-duo-inner': null }; -export const IPHONE_DUO_NOT_IN_API = 'not uploaded: the App Store Connect API has no iPhone Duo display type yet (the console takes them); the renders stay in store/renders'; /** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */ export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01'; const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)'; @@ -75,7 +74,7 @@ const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean; today?: Date }): { level: 'info' | 'warn'; msg: string } | null { if (!o.apple || o.duo) return null; const late = (o.today ?? new Date()).toISOString().slice(0, 10) >= IPHONE_DUO_REQUIRED_FROM; - return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; the App Store Connect console takes Duo screenshots and previews, the API does not yet (apple screenshots skips them with the console link)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` }; + return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; apple screenshots uploads them through the App Asset Library (display class IPHONE_DUO)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` }; } /** validate / check note for an app with a macOS version but no Mac screenshots (apple.mac off or no mac renders). */ diff --git a/cli/validate.ts b/cli/validate.ts index 4d43db3..773359b 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -22,6 +22,7 @@ import { firstProductReport } from './apple/first-products'; import { requiredDisplayTypes } from './apple/screenshots'; import { pendingScreenshots, setRows, type ShotRow, updateLedger } from './apple/screenshot-health'; import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config'; +import { reviewNotes } from './lib/review-details'; import { reviewDemoAccount } from './lib/credentials'; import { allSubscriptions, basePlanId } from './lib/subscriptions'; import type { Ctx } from './lib/ctx'; @@ -34,12 +35,47 @@ 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 { EDIT_GONE, playClient } from './play/client'; import { listOneTimeProducts } from './play/products'; import { creativeNote } from './shots/creative'; import { creativeWaiting, localCreative } from './apple/creative-assets'; +import { liveDeviceFamilies } from './apple/device-families'; +import { type FamilyVerdict, familyVerdicts, projectFamilies } from './lib/device-families'; import { fixFor, GUIDELINE, hitLine } from './lib/cross-store'; import { listingFileMentions } from './lib/listing-mentions'; +import { brokerConfig, brokerPlayInfo, enabledApisOf, grantsOf, type PlayNeed, playGaps, sdkConfigGaps } from './lib/broker'; +import { playCredentials } from './lib/google-auth'; +import { usesPlayGames } from './lib/game-services'; + +/** What the config's Play work needs from the service account: uploads and the listing always, products, + * declarations and Play Games when the config has them. */ +export function playNeedsOf(config: Loaded['config']): PlayNeed[] { + const needs: PlayNeed[] = ['upload', 'listing']; + if ((config.products ?? []).some(p => sellsOn(config, p, 'play')) || allSubscriptions(config.subscriptions).some(x => sellsOn(config, x.sub, 'play'))) needs.push('iap'); + if (config.dataSafety) needs.push('declarations'); + if (usesPlayGames(config)) needs.push('games'); + return needs; +} + +/** With a credential broker: the Play permissions and enabled APIs it lists for the package, the gaps for what the + * config needs (blockers: the commands would get a 403), and SDK config drift. Nothing without a broker. */ +export async function brokerFindings(loaded: Loaded): Promise { + const { config, root } = loaded; + const pkg = config.play?.packageName; + if (!pkg) return []; + let cfg; + try { cfg = brokerConfig(root); } catch (e) { return [{ store: 'play', level: 'warn', code: 'broker-config', msg: (e as Error).message, fix: '~/.storebound/broker.json: { "url", "apiKeyFile" }' }]; } + if (!cfg) return []; + const r = await brokerPlayInfo(cfg, pkg); + if (r.kind === 'not-registered') return [{ store: 'play', level: 'warn', code: 'broker-not-registered', msg: r.message, fix: 'register the package on the credential broker (google_play_set)' }]; + if (r.kind !== 'ok') return [{ store: 'play', level: 'warn', code: 'broker-unavailable', msg: `${r.message}: permission checks skipped`, fix: 'check the credential broker url and API key (~/.storebound/broker.json)' }]; + const out: Finding[] = []; + const grants = grantsOf(r.info), apis = enabledApisOf(r.info); + out.push({ store: 'play', level: 'ok', code: 'broker-access', msg: `credential broker: ${r.info.account ?? 'service account'} on ${pkg}: Play permissions ${grants ? (grants.join(', ') || 'none') : 'not listed'}; enabled APIs ${apis ? (apis.join(', ') || 'none') : 'not listed'}` }); + for (const g of playGaps(r.info, playNeedsOf(config))) out.push({ store: 'play', level: 'blocker', code: g.kind === 'grant' ? 'broker-grant' : 'broker-api', msg: g.message, fix: g.fix, url: g.url }); + for (const m of sdkConfigGaps(r.info, root)) out.push({ store: 'play', level: 'warn', code: 'sdk-config', msg: m }); + return out; +} /** Profile facts for the checks: config + local credentials + public URL checks (no store API). */ export async function collectProfile(loaded: Loaded): Promise { @@ -65,6 +101,14 @@ async function creativeWaitingOf(ctx: Ctx, c: Awaited>, appId: string): Promise { + const { config, root } = ctx.loaded; + try { + return familyVerdicts({ live: await liveDeviceFamilies(c, appId), project: projectFamilies(root, config.apple!.bundleId, config.apple?.build?.project), ipad: config.apple?.ipad }); + } catch (e) { return [{ level: 'warn', msg: `device families not compared: ${(e as Error).message.slice(0, 200)}` }]; } +} + export async function collectApple(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId); @@ -74,7 +118,7 @@ export async function collectApple(ctx: Ctx): Promise { const every = await allVersions(c, appId); const editableOf = (x: J) => APPLE_EDITABLE.has(stateOf(x)); const withEditable = [...new Set(every.filter(editableOf).map(platformOf))]; - const platform: AscPlatform = parsePlatform(ctx.args.get('platform')) ?? (withEditable.length === 1 ? withEditable[0]! : withEditable.includes('IOS') || !every.length ? 'IOS' : platformOf(every[0])); + const platform: AscPlatform = parsePlatform(ctx.args.get('platform')) ?? (withEditable.length === 1 ? withEditable[0]! : withEditable.includes('IOS') || !every.length || every.some(x => platformOf(x) === 'IOS') ? 'IOS' : platformOf(every[0])); const versions = await c.all(versionsPath(appId, platform, 10)); const v: J = (await c.editableVersion(platform)) ?? versions[0] ?? null; const otherPlatformVersions = every.filter(x => platformOf(x) !== platform && editableOf(x)).map(x => ({ versionString: String(x.attributes.versionString), state: stateOf(x), platform: platformOf(x) })); @@ -100,7 +144,7 @@ export async function collectApple(ctx: Ctx): Promise { shotRows.push(...setRows(shots, l.attributes.locale, s.attributes.screenshotDisplayType)); } 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 }); + locales.push({ locale: l.attributes.locale, name: ia.name, subtitle: ia.subtitle, description: l.attributes.description, keywords: l.attributes.keywords, promotionalText: l.attributes.promotionalText ?? undefined, whatsNew: l.attributes.whatsNew ?? undefined, screenshots }); } reviewDetail = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes ?? null; } @@ -140,10 +184,12 @@ export async function collectApple(ctx: Ctx): Promise { creativeWaiting: await creativeWaitingOf(ctx, c, appId), screenshotsPending: pendingScreenshots(shotRows, updateLedger(root, shotRows), Date.now()), configCopyright: config.app.copyright, + deviceFamilies: platform === 'IOS' ? await deviceFamilyVerdicts(ctx, c, appId) : undefined, build, locales, reviewDetail, ageRating, expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x), - requiredDisplayTypes: requiredDisplayTypes(config.apple), + requiredDisplayTypes: requiredDisplayTypes(config.apple, platform), iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play, + macos: platform === 'MAC_OS' ? { notes: reviewNotes(config, 'macos'), gameCenter: config.apple?.macos?.gameCenter, noIap: config.apple?.macos?.noIap } : undefined, macVersion: every.some(x => platformOf(x) === 'MAC_OS'), mac: !!config.apple?.mac, priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'), availability: !!avail?.data, @@ -158,23 +204,25 @@ export async function collectPlay(ctx: Ctx): Promise { 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 ?? []; + // play.read: one edit under the machine lock, deleted only after every read inside has finished, and opened anew once + // when Play invalidates it meanwhile ("This Edit has been deleted"). A bare POST /edits here was neither locked nor + // retried, so another storebound process (or the Console) ending the edit failed the screenshot reads with a false blocker. + return play.read(async editId => { + log('validate', `play: read-only edit ${editId} (deleted afterwards)`); + const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${editId}/details`); + const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${editId}/listings`)).listings ?? []; const listings: PlaySnapshot['listings'] = []; for (const l of raw) { const images: Record = {}; 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; + images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${editId}/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 ?? []) + const tracks = ((await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[]; userFraction?: number }[] }[] }>('GET', `${play.API}/edits/${editId}/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 ?? '') })); // the production track's countries (read-only in the API: set in the console, `console plan play-production-countries`) - const productionCountries = await play.call<{ countries?: unknown[]; restOfWorld?: boolean }>('GET', `${play.API}/edits/${e.id}/countryAvailability/production`) + const productionCountries = await play.call<{ countries?: unknown[]; restOfWorld?: boolean }>('GET', `${play.API}/edits/${editId}/countryAvailability/production`) .then(r => (r.restOfWorld ? -1 : (r.countries ?? []).length)).catch(() => null); const website = details.contactWebsite ?? null; const appAdsLines = config.ads?.appAdsTxt ?? []; @@ -201,9 +249,7 @@ export async function collectPlay(ctx: Ctx): Promise { expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'play')).map(p => p.id), privacyUrl: await checkUrl(config.app.privacyPolicyUrl), website, appAds, appAdsLines, adsLikely, category: playCategoryOf(config), productionCountries, }; - } 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 @@ -231,8 +277,17 @@ export async function run(ctx: Ctx): Promise { if (creative) findings.push({ store: 'apple', level: 'ok', code: 'creative-assets', msg: creative, fix: 'storebound apple creative-assets --dry' }); } else if (a.has('apple')) warn('validate', 'no apple section in the config'); if ((both || a.has('play')) && config.play) { + // the broker's grants first: a missing permission is named before the edit reads hit the same 403 + const access = await brokerFindings(ctx.loaded).catch(e => [{ store: 'play' as const, level: 'warn' as const, code: 'broker-unavailable', msg: (e as Error).message }]); + findings.push(...access); 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)); } + catch (e) { + // a Play edit that vanished under the reads is a tool problem (the retry inside play.read failed too), never a store blocker + const gone = EDIT_GONE.test((e as Error).message); + findings.push(gone + ? { store: 'play', level: 'warn', code: 'edit-gone', msg: `Play validate could not finish its read-only edit (${(e as Error).message.slice(0, 300)}): a tool problem, not a store finding`, fix: 'storebound validate --play (run it again; close other storebound runs and Console saves first)' } + : { store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 600) }, ...rendersFindings('play', local?.play)); + } } else if (a.has('play')) warn('validate', 'no play section in the config'); // Game Center / Play Games Services: entitlement, APP_ID resource, store state vs the config if (config.games || config.apple) { @@ -259,7 +314,9 @@ export async function run(ctx: Ctx): Promise { findings.push(...sortFindings(healthFindings(h, { unreadable: 'ok' }))); } const blockers = findings.filter(f => f.level === 'blocker').length; - if (a.has('json')) printJson({ blockers, findings }); + // where Play tokens come from (MCP validate reads it): "broker" or "file", never a value + const credentials = config.play ? { play: { source: playCredentials(ctx.loaded.root).source } } : {}; + if (a.has('json')) printJson({ blockers, findings, credentials }); 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)'); diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index a4a4abd..e3dbda5 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -27,8 +27,7 @@ State of storebound for whoever picks it up next. Public-safe: no project, app o - The Play Games Services console page paths. - The Games Configuration API bodies (no pinned spec). - Placing a Header and Search Results asset on a live App Store version. -- **iPhone Duo:** App Store Connect's API has no Duo screenshot type yet. Uploads skip with a console link; wire it - when the spec gains the type (`IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts`). +- **iPhone Duo:** uploaded through the App Asset Library (`cli/apple/duo-library.ts`); previews still use the deprecated sets. ## Rules that matter here diff --git a/docs/README.md b/docs/README.md index 0d6d79d..9efa6f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,8 @@ The [README](../README.md) is the short tour; everything in detail lives here. - [Skills](skills.md) - [MCP server and the desktop machine interface](mcp.md) - [Safety model](safety.md) +- [Google access through a credential broker](credential-broker.md): Play tokens and permission checks from a server of + yours, no Google key on the machine - [Idempotency: every write converges](idempotency.md) - [Pitfalls: lessons from real use](pitfalls.md) diff --git a/docs/agents-core.md b/docs/agents-core.md index 0482818..2cb269a 100644 --- a/docs/agents-core.md +++ b/docs/agents-core.md @@ -15,6 +15,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For these you prepare everything, show the dry run, and the owner presses one button or says yes. + **Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds + a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate + per update: `storebound play release --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --yes`, `storebound apple release --manual-release --yes` + (docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the + owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money. **Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too: MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 9388e08..217466b 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -12,7 +12,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | API | operations | GET | other | wrapped by a command | owner only | |---|---:|---:|---:|---:|---:| -| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 232 | 166 | +| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 236 | 166 | | Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 35 | 49 | | Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 | @@ -80,7 +80,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `androidToIosAppMappingDetails_updateInstance` | PATCH | `/v1/androidToIosAppMappingDetails/{id}` | write (dry by default) | | | **appAssetLibraries** (5) | | | | | | `appAssetLibraries_getInstance` | GET | `/v1/appAssetLibraries/{id}` | read | | -| `appAssetLibraries_images_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/images` | read | apple/creative-assets | +| `appAssetLibraries_images_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/images` | read | apple/creative-assets, apple/duo-library | | `appAssetLibraries_images_getToManyRelationship` | GET | `/v1/appAssetLibraries/{id}/relationships/images` | read | | | `appAssetLibraries_videos_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/videos` | read | apple/creative-assets | | `appAssetLibraries_videos_getToManyRelationship` | GET | `/v1/appAssetLibraries/{id}/relationships/videos` | read | | @@ -94,11 +94,11 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | **appAssetLibraryPlacementOrderingRequests** (1) | | | | | | `appAssetLibraryPlacementOrderingRequests_createInstance` | POST | `/v1/appAssetLibraryPlacementOrderingRequests` | write (dry by default) | | | **appAssetLibraryPlacements** (3) | | | | | -| `appAssetLibraryPlacements_createInstance` | POST | `/v1/appAssetLibraryPlacements` | write (dry by default) | apple/creative-assets | -| `appAssetLibraryPlacements_deleteInstance` | DELETE | `/v1/appAssetLibraryPlacements/{id}` | owner only (typed confirm) | apple/creative-assets | +| `appAssetLibraryPlacements_createInstance` | POST | `/v1/appAssetLibraryPlacements` | write (dry by default) | apple/creative-assets, apple/duo-library | +| `appAssetLibraryPlacements_deleteInstance` | DELETE | `/v1/appAssetLibraryPlacements/{id}` | owner only (typed confirm) | apple/creative-assets, apple/duo-library | | `appAssetLibraryPlacements_getInstance` | GET | `/v1/appAssetLibraryPlacements/{id}` | read | | | **appAssetLibraryRefData** (2) | | | | | -| `appAssetLibraryRefData_getCollection` | GET | `/v1/appAssetLibraryRefData` | read | | +| `appAssetLibraryRefData_getCollection` | GET | `/v1/appAssetLibraryRefData` | read | apple/duo-library | | `appAssetLibraryRefData_getInstance` | GET | `/v1/appAssetLibraryRefData/{id}` | read | | | **appAssetLibraryVideos** (6) | | | | | | `appAssetLibraryVideos_createInstance` | POST | `/v1/appAssetLibraryVideos` | write (dry by default) | | @@ -337,12 +337,12 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | **appStoreVersionLocalizations** (14) | | | | | | `appStoreVersionLocalizations_appPreviewSets_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/appPreviewSets` | read | apple/previews, art | | `appStoreVersionLocalizations_appPreviewSets_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/appPreviewSets` | read | | -| `appStoreVersionLocalizations_appScreenshotSets_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/appScreenshotSets` | read | apple/screenshot-health, apple/screenshots, art, validate | +| `appStoreVersionLocalizations_appScreenshotSets_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/appScreenshotSets` | read | apple/device-families, apple/screenshot-health, apple/screenshots, art, validate | | `appStoreVersionLocalizations_appScreenshotSets_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/appScreenshotSets` | read | | | `appStoreVersionLocalizations_createInstance` | POST | `/v1/appStoreVersionLocalizations` | write (dry by default) | listing-store | | `appStoreVersionLocalizations_deleteInstance` | DELETE | `/v1/appStoreVersionLocalizations/{id}` | owner only (typed confirm) | | | `appStoreVersionLocalizations_getInstance` | GET | `/v1/appStoreVersionLocalizations/{id}` | read | | -| `appStoreVersionLocalizations_placements_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/placements` | read | apple/creative-assets | +| `appStoreVersionLocalizations_placements_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/placements` | read | apple/creative-assets, apple/duo-library | | `appStoreVersionLocalizations_placements_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/placements` | read | | | `appStoreVersionLocalizations_searchKeywords_createToManyRelationship` | POST | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | write (dry by default) | | | `appStoreVersionLocalizations_searchKeywords_deleteToManyRelationship` | DELETE | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | owner only (typed confirm) | | @@ -371,13 +371,13 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appStoreVersions_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperimentsV2` | read | | | `appStoreVersions_appStoreVersionExperiments_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionExperiments` | read | | | `appStoreVersions_appStoreVersionExperiments_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionExperiments` | read | | -| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/creative-assets, apple/listing, apple/previews, apple/screenshot-health, apple/screenshots, art, listing-store, onboard, validate | +| `appStoreVersions_appStoreVersionLocalizations_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionLocalizations` | read | apple/creative-assets, apple/device-families, apple/listing, apple/previews, apple/screenshot-health, apple/screenshots, art, listing-store, onboard, validate | | `appStoreVersions_appStoreVersionLocalizations_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionLocalizations` | read | | | `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionPhasedRelease` | read | apple/release, apple/status | | `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionPhasedRelease` | read | | | `appStoreVersions_appStoreVersionSubmission_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionSubmission` | read | | | `appStoreVersions_appStoreVersionSubmission_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionSubmission` | read | | -| `appStoreVersions_build_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/build` | read | apple/listing, apple/resubmit, validate | +| `appStoreVersions_build_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/build` | read | apple/device-families, apple/listing, apple/resubmit, validate | | `appStoreVersions_build_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/build` | read | | | `appStoreVersions_build_updateToOneRelationship` | PATCH | `/v1/appStoreVersions/{id}/relationships/build` | write (dry by default) | apple/listing, apple/resubmit | | `appStoreVersions_createInstance` | POST | `/v1/appStoreVersions` | write (dry by default) | apple/listing | @@ -389,7 +389,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `appStoreVersions_getInstance` | GET | `/v1/appStoreVersions/{id}` | read | apple/listing | | `appStoreVersions_routingAppCoverage_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/routingAppCoverage` | read | | | `appStoreVersions_routingAppCoverage_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/routingAppCoverage` | read | | -| `appStoreVersions_updateInstance` | PATCH | `/v1/appStoreVersions/{id}` | write (dry by default) | apple/listing | +| `appStoreVersions_updateInstance` | PATCH | `/v1/appStoreVersions/{id}` | write (dry by default) | apple/listing, apple/release, apple/resubmit | | **appTags** (3) | | | | | | `appTags_territories_getToManyRelated` | GET | `/v1/appTags/{id}/territories` | read | | | `appTags_territories_getToManyRelationship` | GET | `/v1/appTags/{id}/relationships/territories` | read | | @@ -425,7 +425,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `apps_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | | | `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | | | `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appTags` | read | | -| `apps_assetLibrary_getToOneRelated` | GET | `/v1/apps/{id}/assetLibrary` | read | apple/creative-assets | +| `apps_assetLibrary_getToOneRelated` | GET | `/v1/apps/{id}/assetLibrary` | read | apple/creative-assets, apple/duo-library | | `apps_assetLibrary_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/assetLibrary` | read | | | `apps_backgroundAssets_getToManyRelated` | GET | `/v1/apps/{id}/backgroundAssets` | read | | | `apps_backgroundAssets_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/backgroundAssets` | read | | @@ -459,7 +459,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `apps_gameCenterEnabledVersions_getToManyRelated` | GET | `/v1/apps/{id}/gameCenterEnabledVersions` | read | | | `apps_gameCenterEnabledVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterEnabledVersions` | read | | | `apps_getCollection` | GET | `/v1/apps` | read | apple/asc, apple/bundle, apps, console/app-id, console/verify, lib/store-apps | -| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status, apps, onboard, watch | +| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status, apple/testflight, apps, onboard, watch | | `apps_inAppPurchasesV2_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchasesV2` | read | apple/first-products, apple/iap, apple/iap-review, apple/iap-submit, apple/offer-codes, apple/promoted, apple/status, catalog, prices, validate | | `apps_inAppPurchasesV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/inAppPurchasesV2` | read | | | `apps_inAppPurchases_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchases` | read | | @@ -620,7 +620,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `buildBundles_appClipDomainDebugStatus_getToOneRelationship` | GET | `/v1/buildBundles/{id}/relationships/appClipDomainDebugStatus` | read | | | `buildBundles_betaAppClipInvocations_getToManyRelated` | GET | `/v1/buildBundles/{id}/betaAppClipInvocations` | read | | | `buildBundles_betaAppClipInvocations_getToManyRelationship` | GET | `/v1/buildBundles/{id}/relationships/betaAppClipInvocations` | read | | -| `buildBundles_buildBundleFileSizes_getToManyRelated` | GET | `/v1/buildBundles/{id}/buildBundleFileSizes` | read | | +| `buildBundles_buildBundleFileSizes_getToManyRelated` | GET | `/v1/buildBundles/{id}/buildBundleFileSizes` | read | apple/device-families | | `buildBundles_buildBundleFileSizes_getToManyRelationship` | GET | `/v1/buildBundles/{id}/relationships/buildBundleFileSizes` | read | | | **buildUploadFiles** (3) | | | | | | `buildUploadFiles_createInstance` | POST | `/v1/buildUploadFiles` | write (dry by default) | lib/xcode | @@ -647,12 +647,12 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `builds_betaBuildUsages_getMetrics` | GET | `/v1/builds/{id}/metrics/betaBuildUsages` | read | | | `builds_betaGroups_createToManyRelationship` | POST | `/v1/builds/{id}/relationships/betaGroups` | write (dry by default) | | | `builds_betaGroups_deleteToManyRelationship` | DELETE | `/v1/builds/{id}/relationships/betaGroups` | owner only (typed confirm) | | -| `builds_buildBetaDetail_getToOneRelated` | GET | `/v1/builds/{id}/buildBetaDetail` | read | | +| `builds_buildBetaDetail_getToOneRelated` | GET | `/v1/builds/{id}/buildBetaDetail` | read | apple/testflight | | `builds_buildBetaDetail_getToOneRelationship` | GET | `/v1/builds/{id}/relationships/buildBetaDetail` | read | | | `builds_diagnosticSignatures_getToManyRelated` | GET | `/v1/builds/{id}/diagnosticSignatures` | read | health | | `builds_diagnosticSignatures_getToManyRelationship` | GET | `/v1/builds/{id}/relationships/diagnosticSignatures` | read | | -| `builds_getCollection` | GET | `/v1/builds` | read | apple/build, apple/build-mas, apple/encryption, apple/listing, apple/next-build, apple/resubmit, apple/status, apple/testflight, apple/upload, art, health, lib/store-apps, triage | -| `builds_getInstance` | GET | `/v1/builds/{id}` | read | | +| `builds_getCollection` | GET | `/v1/builds` | read | apple/encryption, apple/listing, apple/next-build, apple/resubmit, apple/status, apple/testflight, apple/upload, art, health, lib/store-apps, triage | +| `builds_getInstance` | GET | `/v1/builds/{id}` | read | apple/device-families | | `builds_icons_getToManyRelated` | GET | `/v1/builds/{id}/icons` | read | | | `builds_icons_getToManyRelationship` | GET | `/v1/builds/{id}/relationships/icons` | read | | | `builds_individualTesters_createToManyRelationship` | POST | `/v1/builds/{id}/relationships/individualTesters` | owner only (typed confirm) | | @@ -672,11 +672,11 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `bundleIds_app_getToOneRelationship` | GET | `/v1/bundleIds/{id}/relationships/app` | read | | | `bundleIds_bundleIdCapabilities_getToManyRelated` | GET | `/v1/bundleIds/{id}/bundleIdCapabilities` | read | | | `bundleIds_bundleIdCapabilities_getToManyRelationship` | GET | `/v1/bundleIds/{id}/relationships/bundleIdCapabilities` | read | | -| `bundleIds_createInstance` | POST | `/v1/bundleIds` | write (dry by default) | apple/bundle | +| `bundleIds_createInstance` | POST | `/v1/bundleIds` | write (dry by default) | apple/bundle, apple/signing | | `bundleIds_deleteInstance` | DELETE | `/v1/bundleIds/{id}` | owner only (typed confirm) | | | `bundleIds_getCollection` | GET | `/v1/bundleIds` | read | apple/adhoc, apple/bundle, apple/signing | | `bundleIds_getInstance` | GET | `/v1/bundleIds/{id}` | read | | -| `bundleIds_profiles_getToManyRelated` | GET | `/v1/bundleIds/{id}/profiles` | read | apple/adhoc, lib/signing | +| `bundleIds_profiles_getToManyRelated` | GET | `/v1/bundleIds/{id}/profiles` | read | apple/adhoc, apple/signing, lib/signing | | `bundleIds_profiles_getToManyRelationship` | GET | `/v1/bundleIds/{id}/relationships/profiles` | read | | | `bundleIds_updateInstance` | PATCH | `/v1/bundleIds/{id}` | write (dry by default) | | | **certificates** (7) | | | | | @@ -1565,9 +1565,9 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `androidpublisher.appstorecatalog.recentupdateevents.list` | GET | `androidpublisher/v3/appstorecatalog/{appStorePackageName}/recentUpdateEvents` | read | | | **androidpublisher.edits** (5) | | | | | | `androidpublisher.edits.commit` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}:commit` | owner only (typed confirm) | play/client | -| `androidpublisher.edits.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | owner only (typed confirm) | apps, onboard, play/client, validate | +| `androidpublisher.edits.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | owner only (typed confirm) | apps, onboard, play/client | | `androidpublisher.edits.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}` | read | | -| `androidpublisher.edits.insert` | POST | `androidpublisher/v3/applications/{packageName}/edits` | write (dry by default) | apps, onboard, play/client, validate | +| `androidpublisher.edits.insert` | POST | `androidpublisher/v3/applications/{packageName}/edits` | write (dry by default) | apps, onboard, play/client | | `androidpublisher.edits.validate` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}:validate` | write (dry by default) | play/client | | **androidpublisher.edits.apks** (3) | | | | | | `androidpublisher.edits.apks.addexternallyhosted` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/externallyHosted` | write (dry by default) | | @@ -1592,7 +1592,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | **androidpublisher.edits.images** (4) | | | | | | `androidpublisher.edits.images.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}/{imageId}` | owner only (typed confirm) | | | `androidpublisher.edits.images.deleteall` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}` | owner only (typed confirm) | play/images | -| `androidpublisher.edits.images.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}` | read | apps, art, play/images, validate | +| `androidpublisher.edits.images.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}` | read | apps, art, play/images, play/listing, validate | | `androidpublisher.edits.images.upload` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}/{imageType}` | write (dry by default) | play/images, play/listing | | **androidpublisher.edits.listings** (6) | | | | | | `androidpublisher.edits.listings.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | owner only (typed confirm) | | @@ -1600,7 +1600,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | `androidpublisher.edits.listings.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | read | onboard | | `androidpublisher.edits.listings.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | read | art, listing-store, play/listing, play/status, validate | | `androidpublisher.edits.listings.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store | -| `androidpublisher.edits.listings.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store, play/listing | +| `androidpublisher.edits.listings.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store, play/images, play/listing | | **androidpublisher.edits.testers** (3) | | | | | | `androidpublisher.edits.testers.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/testers/{track}` | read | | | `androidpublisher.edits.testers.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/testers/{track}` | owner only (typed confirm) | | diff --git a/docs/bypass.md b/docs/bypass.md index c82b6fc..ad50749 100644 --- a/docs/bypass.md +++ b/docs/bypass.md @@ -11,9 +11,34 @@ storebound settings bypass off # back to the owner pressing every final butt It is stored in `~/.storebound/settings.json` (this machine only), never in a project's config or repo. +## Updates are agent steps (no bypass needed) + +The owner's standing rule: a new version of an app that is **already on the store** ships end to end by the agent or the +pipeline, with no owner gate per update: submit for review, promote to production, roll out, release an approved +version. This holds with bypass mode off, in a terminal and in CI: + +| Store | Update commands (all take `--yes`; `--json` / `--result-file` for the [ship-step contract](ship-step.md)) | +|---|---| +| Google Play | `storebound play release --production completed --yes` (or `--production inProgress --fraction 0.05`), or `storebound play upload --track production --yes` | +| App Store | `storebound apple submit --build --yes`, then (manual release) `storebound apple release --manual-release --yes` | + +What makes it an update is read from the store, never from the config: Play's production track holds a completed / +inProgress / halted release; the App Store platform has a version that is or was `READY_FOR_SALE` +(`READY_FOR_DISTRIBUTION`, `REPLACED_WITH_NEW_VERSION`). The result line carries `releaseKind: "update"`. + +Still the owner's, bypass mode or not (the commands refuse with exit 3, `error.code` `OWNER_STEP`, "first release of this +app"): the **first-ever release** of an app (the first production release on Play, the first submission or release on +the App Store), removal from sale, live price changes and anything that costs money. The owner approves a first +release once: after the owner's yes in chat re-run with `--first-release` (or turn on bypass mode). A person at a +terminal answering the normal confirmation prompt (no `--yes`) is the owner's approval too, and so is the desktop's +owner dialog. Re-running an update that is on the store already is exit 7, nothing sent. + +MCP is unchanged: it still does not offer `--yes` or `--production`; an update goes through the CLI in a terminal or +pipeline (or `owner_step` in bypass mode). + ## What it unlocks -- MCP: the `owner_step` tool appears. It runs one of: `apple submit`, `apple resubmit`, `apple iap-submit`, `apple release`, +- MCP: the `owner_step` tool appears. It runs one of (a first release also passes the gate in bypass mode): `apple submit`, `apple resubmit`, `apple iap-submit`, `apple release`, `apple preorder`, `play release`, `play rollout`, `reviews` (replies), `apple testflight` (testers), `play products`, `play subscriptions`, `apple iap`, `apple subscriptions`, `prices` (live prices). Dry run first (`dry_run` defaults to true), then `dry_run: false` runs it with `--yes`. diff --git a/docs/commands.md b/docs/commands.md index 0d3ee56..4e3b204 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -87,7 +87,7 @@ run in the background with a log (`storebound shots render > .storebound/render. | `apple game-center [status]` | API | Game Center from `games`: the app's Game Center detail, the version switch, leaderboards and achievements with localizations (upsert by vendor id, read back; nothing submitted); dry first, asks | | `games status [--json]` | API, read-only | Game Center and Play Games Services on both stores: capability, entitlement, games_APP_ID, counts vs the config, pending changes, console links | | `apple encryption` | API | export compliance answer from `apple.usesNonExemptEncryption` (never guessed) | -| `apple release --phased on\|pause\|resume\|complete`, `--manual-release` | API | phased release, release an approved version (resume / complete / release ask first) | +| `apple release --phased on\|pause\|resume\|complete`, `--manual-release`, `--after-approval \| --manual \| --scheduled ` | API | phased release, release an approved version (resume / complete / release ask first), how an unapproved version goes live (`apple.releaseType`; exit 7 when already that type) | | `apple signing status` | API, read-only | bundle id capabilities, distribution certificates (with / without a private key on this Mac), App Store profiles per bundle id, installed or not | | `apple signing ensure [--keychain] [--no-install]` | API | missing capabilities, a distribution certificate from a key made here (kept 0600 in `.storebound/signing/`, never printed), App Store profiles installed for Xcode; dry first, asks; Developer ID needs the Account Holder (403 explained) | | `apple build [--version x.y.z] [--build-number N]` | local + API read | `build.prebuild` first (Capacitor: web build + cap sync; stale copied web assets stop the build), then xcodebuild archive + exportArchive with a generated ExportOptions.plist (app-store-connect, team, signing style, profiles) and the API key; next build number; Capacitor, React Native, Flutter, Unity projects found | @@ -127,9 +127,9 @@ run in the background with a log (`storebound shots render > .storebound/render. | `prices export [--no-subscriptions]` | API + local | current prices of every product and subscription (Apple current + scheduled, Play base plan regions), both stores, as `store/prices.csv`; subscription rows feed `apple subscriptions --csv` / `play subscriptions --csv` | | `prices import [--reconvert]` | API | diff table, then (asks first) Apple price schedules incl. future-dated changes and Play regional prices | | `analytics setup` | API | App Store analytics report request (once, Admin key) | -| `analytics snapshot [--days 28]` | API + local | impressions, page views, downloads (App Store), visitors, acquisitions, installs (Play reports bucket) -> `.storebound/analytics/` | +| `analytics snapshot [--days 28] [--store apple\|play] [--json] [--no-write]` | API + local | impressions, page views, downloads, App Usage (sessions, active devices, crashes) per day (App Store); visitors, acquisitions, daily user installs, active device installs, total user installs (Play reports bucket, also from env `PLAY_REPORTS_BUCKET` or the profile's `reportsBucket`) -> `.storebound/analytics/`; `--json` prints the snapshot on stdout; a store-only config needs `apple.appId` only | | `analytics compare` | local | snapshots vs the git history of the store files: before / after each listing change | -| `reports apple-sales \| apple-finance \| play-earnings \| play-sales` | API, read-only + local | sales / finance reports into `.storebound/reports/`, summary per product and currency, CSV export | +| `reports apple-sales \| apple-finance \| play-earnings \| play-sales` | API, read-only + local | sales / finance reports into `.storebound/reports/`, summary per product and currency, CSV export; `apple-sales` and `play-sales` take `--days N` or `--from/--to` for a per-day JSON (daily summary, totals per currency, missing days) | | `triage [--days 14]` | API, read-only | TestFlight crash and screenshot feedback, Play error reports: grouped by signature with devices, OS versions, builds | | `aso rank [--storefronts us,de]` | network + local | App Store rank per keyword and storefront plus competitors from the public iTunes Search API (rate limited) -> `.storebound/aso/` | | `aso competitors [--term ] [--storefront us,tr] [--top 10]` | network + local | top apps per keyword (name, developer, rating count, first release, last update, localized genre; no subtitles: not in any public API) and the words in their names you do not cover, same rate limit -> `.storebound/aso/competitors/` | diff --git a/docs/credential-broker.md b/docs/credential-broker.md new file mode 100644 index 0000000..749b6c0 --- /dev/null +++ b/docs/credential-broker.md @@ -0,0 +1,85 @@ +# Google access through a credential broker + +A credential broker is a server of yours that holds the Google service-account keys, the Cloud project facts and the +Play permission grants. With one configured, storebound keeps no Google key on the machine: it asks the broker for a +short-lived access token for each Google API call and reads the package's Play permissions from it before a command +writes anything. The local key (`PLAY_SERVICE_ACCOUNT`) keeps working and is the fallback. + +## Setup + +`~/.storebound/broker.json`: + +```json +{ + "url": "https://broker.example.com", + "apiKeyFile": "~/.storebound/broker.key", + "account": "play-publisher@example.com" +} +``` + +- `url`: the broker's base URL. Only `https://` (plain `http://` only for `localhost`, a local test broker). +- `apiKeyFile`: a file with one scoped API key on one line, with the scopes `google:read` and `google:token`. On macOS + and Linux it must be `chmod 600`: storebound refuses a key file that the group or others can read. Default: + `broker.key` next to `broker.json`. The key is never printed, never written into a config or a log. +- `account` (optional): the service account e-mail the broker mints tokens for. A credential profile or the project + can name its own with `PLAY_SERVICE_ACCOUNT_EMAIL` (env, `~/.storebound/credentials/.json`, + `.storebound/credentials.json`); without either, the `client_email` of a local key is used, and for Play commands + the account the broker names for the package. + +Overrides: `STOREBOUND_BROKER_URL`, `STOREBOUND_BROKER_KEY_FILE` (env wins over the file), `STOREBOUND_BROKER_CONFIG` +(another broker.json). Off: `STOREBOUND_BROKER=off` for one run, `"broker": false` in a credential profile (that profile +keeps its key file), or env `STOREBOUND_PROFILE__BROKER=off`. + +## What storebound calls + +| call | when | +|---|---| +| `POST /api/google/token` `{ "account", "scopes": ["androidpublisher"] }` -> `{ "accessToken", "expiresAt" }` | before Google calls. Kept in memory until 5 minutes before `expiresAt`; parallel calls share one request | +| `GET /api/google/play//storebound` | `storebound validate`, the preflight of Play write commands, `apps check` and the desktop's Accounts view | + +Scopes: `androidpublisher` for the Play Developer API, `cloud-platform.read-only` for Play statistics exports and Play +Games configuration reads. The broker hands out no scope for the Play Developer Reporting API (vitals, the app list of +`apps list`) or for Play Games configuration writes: those use the local key, and say so when there is none. + +## Fallback and failures + +- Broker not configured: the local key, as before. +- Broker not answering (network error, timeout, 5xx) or not knowing the account (400 / 404 on a token): the local key, + with one warning per run. No local key: the error names the broker. +- Broker refusing the API key (401 / 403): storebound stops with "the key lacks google:token" and does not retry or + fall back. Give the key its scopes on the broker. +- A package the broker does not know (404 on `/storebound`): a warning to register it there. It is not a missing + Play access; the command still runs and Google answers for itself. + +## Permission checks + +The broker's answer lists the service account's Play permissions for the package and the APIs enabled in its Cloud +projects. Before a Play command writes, storebound compares them with what the command needs and stops before any +Play call, naming the exact permission or API and the link to fix it: + +| work | Play permission | commands | +|---|---|---| +| uploads, releases, rollouts | `CAN_MANAGE_TRACK_APKS` | `play upload`, `play release`, `play rollout`, `play share` | +| store listing and images | `CAN_MANAGE_PUBLIC_LISTING` | `play listing`, `play images` | +| products and subscriptions | `CAN_MANAGE_ORDERS` | `play products`, `play subscriptions` | +| Data safety, app content | `CAN_MANAGE_APP_CONTENT` | `play data-safety` | +| review replies | `CAN_REPLY_TO_REVIEWS` | (listed in the checks) | +| Play Games Services | API `gamesconfiguration.googleapis.com` | `play games` | + +Every Play call also needs `androidpublisher.googleapis.com` enabled. A dry run (`--dry`) and reads only warn. +`storebound validate` reports the same gaps as blockers, the permissions and APIs the broker lists, and SDK config +files (`google-services.json`, `GoogleService-Info.plist`) the broker reports as drifted or whose sha256 differs from +the repo's copy. + +When Google itself answers 403 `SERVICE_DISABLED` or `PERMISSION_DENIED`, the error names the API (with its enable +link) or the missing app access (with the Users and permissions link), adds what the broker lists for the package, and +is never retried. + +## Where it shows + +- `storebound doctor`: `Play: tokens from the credential broker for p***@...` (or the local key, and why the + broker is not usable). `--json`: `play.source` is `"broker"` or `"file"`. +- `storebound accounts`: a "Play via" column (credential broker / key file) per profile. +- `storebound validate --json` (and MCP `validate`): `credentials.play.source`. +- `storebound apps check --store play --json` and the desktop's Accounts view: `source` and, per Play app of the + machine, the permissions and APIs the broker lists and what is missing. diff --git a/docs/desktop.md b/docs/desktop.md index 5e96ff4..63d3c76 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -490,6 +490,25 @@ The CLI and the app are one product: one version number, one set of store accoun starts silently in the tray, without a window. Closing the window keeps it running in the tray; the tray icon opens it, the tray menu's Quit (macOS: Cmd+Q) quits. Turn the logon entry off in the OS (Windows: Settings > Apps > Startup). Window state never restores "visible", so a `--hidden` start stays hidden. +- **Tray menu panels** (`src-tauri/src/menu_panel.rs`, `src/traymenu/`, page `tray-menu.html`): a right click on the + tray icon opens the app's own menu instead of the OS menu. tray.rs builds one tree (`menu_model`, submenus with a + stable key such as `apps`, the same in every language); every panel (the root and each submenu) is its own + frameless, always-on-top `menu-N` window that measures itself and calls `menu_ready`, and Rust places it inside the + monitor's work area (the root away from the taskbar or menu bar, a submenu beside its parent, flipped at the edge). + The panel book (pure, `tests/menu_panel.rs`) keeps transient and pinned panels apart: a new menu, Esc, a pick or + focus leaving every menu closes the transient ones; pinned ones (pin button, Ctrl+click, middle click) stay, can be + dragged by the header, step aside from other pins and are saved in state.json (`menuPins`) and restored at start. + `tray_update` pushes the tree to the open panels (`menu-model`), which update in place; nothing polls, and a closed + panel is destroyed. Only `menu-*` windows get the `menu_*` commands (`capabilities/menu.json`, `guard_menu`). If a + panel cannot be built or never reports ready, the native menu (rendered from the same tree) goes on the tray and + pops once; Linux always uses it (its tray sends no clicks). The left-click card stays as it was. Look at the panels + without Tauri: `bunx vite` in apps/desktop and open `/tray-menu.html` (a dev-only demo on fixture data; `?still=1` + stops the ticking values). +- **Pinned context menus in the window** (`src/ui/ContextMenu.tsx`, pure stack in `src/ui/menu-stack.ts`): every menu + has a pin button; a submenu's pin button, Ctrl / Cmd + click or a middle click on a submenu row tears it off. Pinned + panels survive the next menu, are dragged by the header and re-read their entries from the newest builder the screen + registered (`useContextMenu()` / `useMenuButton()` register on every render; pass an id such as `app:` when rows + can reorder), so a store read updates them in place; when the thing is gone they say so instead of closing. - **`storebound desktop` install / update**: before the installer runs it stops the running app (only processes started from the exact install path: `%LOCALAPPDATA%\storebound\storebound-desktop.exe`, `storebound.app/Contents/MacOS/storebound-desktop`, the AppImage; a graceful close, then by PID after 10 s). After the diff --git a/docs/getting-started.md b/docs/getting-started.md index ea6c825..6301f0f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -115,12 +115,16 @@ Store accounts belong to the machine, everything else to the project: `.storebound/credentials.json`. - **Order:** environment variables win over everything, then a profile the project names, then the project file, then the default profile. +- **Google keys on a server of yours:** with a credential broker in `~/.storebound/broker.json`, Play tokens come from + it and the machine needs no Google key; the local key stays the fallback. See + [Google access through a credential broker](credential-broker.md). The names (env, profile and project file alike; the project file is gitignored by `init`): | name | what | |---|---| | `PLAY_SERVICE_ACCOUNT` | path to the Google Cloud service account JSON key (or the JSON itself) with access to your app in Play Console | +| `PLAY_SERVICE_ACCOUNT_EMAIL` | with a [credential broker](credential-broker.md): the service account the broker mints tokens for (no key file needed) | | `ASC_KEY_ID`, `ASC_ISSUER_ID` | App Store Connect API team key (Users and Access > Integrations) | | `ASC_P8` | path to `AuthKey_.p8` (default `~/.appstoreconnect/private_keys/AuthKey_.p8`) | | `ASC_VENDOR_NUMBER` | vendor number for `reports apple-sales` / `apple-finance` (Payments and Financial Reports) | diff --git a/docs/mcp.md b/docs/mcp.md index 0e8b66c..08a17ff 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,6 +24,84 @@ per-request `_meta`, `resultType`, cacheable `tools/list`) and, on the same proc Manual config for a tool that `install` does not know: command `bunx` with args `["storebound", "mcp"]` (or the absolute path of `bin/storebound` plus `mcp`). +## One shared server over HTTP + +`storebound mcp --http` serves the same tools over Streamable HTTP (MCP 2025-06-18 and later) at `/mcp`, so every +agent session of a machine shares one process instead of starting its own. POST carries one JSON-RPC message and gets +one JSON answer; there is no server-initiated stream and no session state. A browser Origin other than localhost is +refused unless `--allow-origin` names it. + +- Every tool gets a `project` argument: the absolute path of the app folder to run in. It must be inside `--roots` + (comma-separated, default the home folder). `--project ` sets a default for calls without one. +- `--auth token` (default): the server makes `~/.storebound/mcp-token` (mode 600) on first start and requires it as a + bearer token. Only localhost hosts are allowed with it. + + ```bash + storebound mcp --http --port 7787 + claude mcp add --transport http storebound http://127.0.0.1:7787/mcp --header "Authorization: Bearer $(cat ~/.storebound/mcp-token)" + ``` +- `--auth introspect`: for a server shared over the network behind TLS. Bearer tokens are OAuth 2.0 access tokens from + your authorization server, checked through its introspection endpoint (RFC 7662; this server authenticates with the + key in `--introspect-key-file`). A token must be active, carry the `mcp` scope and list `--resource` in `aud` + (RFC 8707). Without one the answer is 401 with `WWW-Authenticate: Bearer resource_metadata=...`, and + `/.well-known/oauth-protected-resource/mcp` names `--issuer` (RFC 9728). + + ```bash + storebound mcp --http --host 0.0.0.0 --auth introspect --introspect-url https://auth.example.com/introspect \ + --introspect-key-file ~/.storebound/introspect.key --resource https://mcp.example.com/mcp --issuer https://auth.example.com + ``` +- `--read-only`: only the tools that change nothing are listed and callable. +- `--broker-profiles a,b`: a host that keeps no key files takes these credential profiles from the credential broker's + vault (`GET /api/store-credentials//asc-api-key | play-service-account`, a key with the `store-credentials` + scope; docs/credential-broker.md). They reach each tool call as `STOREBOUND_PROFILE__*` env, held in memory for + 10 minutes and never written. A project picks its profile as usual (the config's `account`). +- `project` may also be a folder name under the roots (a shared server keeps its checkouts there). +- Long calls: a `tools/call` whose request accepts `text/event-stream` is answered as SSE, with a comment line every + 20 s until the result arrives as one `message` event, so a proxy that cuts silent responses (Cloudflare: 100 s) + keeps the connection. Other requests get plain JSON. +- Long calls: a `tools/call` whose request accepts `text/event-stream` is answered as SSE, with a comment line every + 20 s until the result arrives as one `message` event, so a proxy that cuts silent responses (Cloudflare: 100 s) + keeps the connection. Other requests get plain JSON. +- `/health` answers `{ ok, version }` without auth. + + +## Public key-less server + +`storebound mcp --http --public [--port 7788] [--host 127.0.0.1] [--rate 60] [--trust-proxy]` is a separate mode for a +server anyone may connect to (for example behind a public HTTPS address). No sign-in, no token, no project: it never +reads store credentials, project folders or the working directory, never starts a child process and never runs the CLI. +It is read-only and keeps no state. The only network call it makes is the public iTunes Search API (`aso_search`). +It cannot be combined with `--auth`, `--token-file`, `--roots`, `--project`, `--broker-profiles`, `--allow-origin` +or `--read-only`. + +Tools (and only these; the CLI tools of the normal server do not exist here, a call gets "Unknown tool"): + +- `skills_list`: name and one-line description of every skill in the package. +- `skill_get {name}`: the full SKILL.md of one skill (`^[a-z0-9-]+$`, must be in the list). +- `pitfalls_search {query, store?}`: the sections of the store-pitfalls skill that match the query words, best first (at + most 8, each clipped). `store` is `apple` or `play`. +- `store_limits {store?}`: listing field character limits, the App Store keyword field rules, screenshot and app + preview sizes per device class, Play feature graphic and icon sizes. +- `listing_lint {store, fields, locale?}`: storebound's listing and ASO checks on the text you send (limits, keyword + field rules, repeated name words, other platforms or apps named, placeholder text, keyword stuffing). Apple fields: + `name`, `subtitle`, `keywords`, `promotionalText`, `description`, `whatsNew`. Play fields: `title`, + `shortDescription`, `fullDescription`, `whatsNew`. Only the fields you send are judged; nothing is stored. +- `aso_search {term, country?, limit?}`: top apps for a term in one storefront from the iTunes Search API (name, + developer, id, rating, rating count, genre; 1-25 results, 10 s timeout). + +Limits: `--rate N` requests per minute per client (default 60; over the limit the answer is 429 with `Retry-After`), +bodies over 64 KB get 413, every tool input is validated against a closed schema, and output is bounded. Behind a +reverse proxy add `--trust-proxy` so the first `X-Forwarded-For` entry is the client; without it that header is +ignored. Any browser origin may call it (there is no ambient credential), and CORS preflight is answered. `/health` +works as before. Request bodies are not logged. + +```bash +storebound mcp --http --public --port 7788 --trust-proxy +claude mcp add --transport http storebound-public https://mcp.example.com/mcp +``` + +For real store work (builds, uploads, listings, releases) use `npx storebound` and its own MCP server. + ## Desktop machine interface `storebound serve --stdio` is what the desktop app (`apps/desktop`, in progress) runs as its sidecar: newline-delimited diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 4f58aed..ca3710c 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,8 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 96 | 2026-10 | A pipeline could not ship a production update of a live app: production was treated as the owner's for every release, though the owner's rule is updates end to end and only the first-ever release gated. | `cli/lib/release-kind.ts` reads update vs first from the store; `--yes` ships an update (Play `play release --production` / `play upload --track production`, App Store `apple submit` / `apple release`), a first release with `--yes` is exit 3 `OWNER_STEP` (`--first-release` or bypass approve it), a re-run is exit 7; `tests/update-gate.test.ts` | +| 95 | 2026-10 | The Google service-account keys moved to the owner's credential broker, but storebound still wanted a local `PLAY_SERVICE_ACCOUNT`, and a Play write with a missing permission ran its edit before Google's bare 403. | `cli/lib/broker.ts` (broker.json, key file mode 600, token cache and single flight, grants, `playPreflight` before Play write commands), `playToken` in `cli/lib/google-auth.ts` (broker first, local key fallback when it does not answer), `cli/lib/google-access.ts` (403 SERVICE_DISABLED / PERMISSION_DENIED -> exact API or grant with its link, never retried), `validate` blockers; `tests/credential-broker.test.ts` against a local fake broker. Skill pitfall 122, docs/credential-broker.md. | | 94 | 2026-10 | An app with managed publishing was approved but never went live: `play status` said "In review / ready to publish" with no next step, and the Publishing overview "Publish N changes" button (production + Data safety) was easy to miss or to confuse with Discard changes. | `console plan play-publish-changes` (final "Publish N changes" is the owner's, Discard changes / Stop managed publishing never-press), `console verify play-publish-changes` (API), the hint in `play status`, `status` and the desktop Releases tab (`cli/lib/play-publish.ts`, `tests/console-play-plans.test.ts`). | | 93 | 2026-10 | A git-ignored, never-captured frames folder rendered store images with no app or game in them; render only logged 404s and `shots check` said 0 errors. | `shots render` refuses to start while a scene frame is missing (`framesForRender` in `cli/shots/lib.ts`) and fails when any load failed; report.json `missing` is a `frame-missing` error in `shots check` / `validate` (`cli/shots/report.ts`, `tests/shots-missing-frames.test.ts`). | | 90 | 2026-10 | `apple submit --build N --version X --yes` after `apple submit --no-submit --yes` (the documented two-step flow) printed the plan (every item "in it already", then "submit PATCH submitted: true"), logged "not added ...: already in the draft submission" and ended with "nothing to submit" without the PATCH: the submit only ran for new items or an UNRESOLVED_ISSUES submission. | `willSubmit` (cli/lib/review-submit.ts) also submits a READY_FOR_REVIEW draft that already holds the named app version (iap-submit: one of the asked-for items), with the same "not ready yet" retry. `tests/resubmit.test.ts` ("the second step submits the prepared draft"). | @@ -61,6 +63,7 @@ fails without the fix. | 27 | 2026-10 | A real Play first release (an agent with a browser tool) found the steps no API does: the App category stayed "Not selected" (`play listing` cannot set it: edits.details has no category field); production had 0 countries (edits.countryAvailability is read-only); App content had ten cards with their own pages; the IARC questionnaire is one long page whose Yes answers reveal follow-ups, with an IARC Terms of Use agreement on page 1 and a final "Save" (not "Submit") on the Summary; Play's material checkboxes, like its radios, ignore native input; long evaluate calls timed out at 45 s; "Send changes for review" ignored a JS click; managed publishing is off by default, so approved means live. | Plans `play-store-settings`, `play-app-content` (+ one per card), `play-production-countries`, `play-production-release`; config `play.category` (Play's lists), `play.countries`, `play.managedPublishing`, `console.contentRating.details`, `console.appContent`, `social`, `products[].randomRewards`; every ask-owner answer in one batch; `check` warns without a category and counts the open App content answers; `validate` reports the category reminder and "production has 0 countries"; `play status` and `play release` say what is console-only; the IARC Terms box is never-press; material checkboxes get the inner [role=checkbox] click (plan and `console fill`); every plan ships `inPageLoop`. `tests/console-play-plans.test.ts`. | | 26 | 2026-10 | An agent using storebound spent hours handing the owner one click, command or answer at a time ("press Next", "download the CSV template", "now run the upload", a compliance question in the middle of a run): skills said "let the owner do it" / "by hand" for navigation, exports and uploads. | The first rule "Do it, don't ask" heads `docs/agents-core.md` (every generated adapter), README and the store-pitfalls skill: the agent does API calls, uploads, builds, exports, console navigation and forms itself; the owner presses only the final public or irreversible buttons and answers the facts no file proves in one batch at the start. `tests/do-it-dont-ask.test.ts` checks the rule is there and fails on skill text that hands navigation, draft saves, exports, uploads or form filling to the owner. | | 25 | 2026-10 | In a non-interactive shell `play build` stopped in Gradle with "SDK location not found" (no `ANDROID_HOME` there, `local.properties` gitignored) although the SDK was in `~/Library/Android/sdk`; `apple upload --no-wait --yes` sent the IPA for over 10 minutes without a line (progress every 10th part only, no per-part or per-call timeout). | `resolveAndroidSdk` (ANDROID_HOME, ANDROID_SDK_ROOT, `sdk.dir` in local.properties, the Android Studio default folder; only folders with platforms/ or platform-tools/) hands the SDK to Gradle in the environment, never writes local.properties, stops before Gradle with the places checked; `doctor` shows it (`cli/lib/tools.ts`, `cli/play/build.ts`, `cli/doctor.ts`). `apple upload` prints the size, a line per part and a 20 s heartbeat (api, altool, processing wait), per-part and per-call timeouts, `--timeout` exits 7 with the resume hint (`sendParts` in `cli/apple/upload.ts`, `cli/lib/progress.ts`; `tests/tools-path.test.ts`, `tests/build-sign.test.ts`, `tests/upload-progress.test.ts`). | +| 25 | 2026-10 | `apple build --out ` still used `~/Library/Developer/Xcode/DerivedData`, and the app binary plus the archive's dSYMs / `Symbols/*.symbols` held the home folder, user name and checkout path (543 privacy-gate hits). | `--derived-data` / default `/DerivedData`, `--path-free` (Swift and clang prefix maps) with an archive scan that stops before the export: `cli/lib/path-free.ts`, `tests/path-free.test.ts`; store-pitfalls 124. | | 24 | 2026-10 | `flow run release --apply --yes` ran `apple upload --no-wait`, which printed "not a terminal: re-run with --yes to confirm" and declined, yet the flow logged "4/13 upload: ok" and went on to `apple testflight wait` for a build that was never uploaded: a declined confirmation exited 0, and the flow's `--yes` never reached the steps. | `confirm()` (`cli/lib/ctx.ts`): every decline (answered no, not a terminal without `--yes`, under serve) prints `declined: ` and exits 3 (`DECLINED_EXIT`). `flow run` reports "step N declined: " (status `declined`) and stops; `--apply --yes` adds `--yes` to write steps only (`planStep`, `declinedReason`; `tests/flow.test.ts`). | | 23 | 2026-10 | Capacitor: native build ships old web assets. `flow run release --apply` went straight into `apple build` (xcodebuild archive) with `ios/App/App/public` and `android/app/src/main/assets/public` 3.5 hours older than the latest source commit: the native projects hold the copy `npx cap sync` made last, and nothing ran the web build + cap sync first, so the old game would have shipped. | New `build.prebuild` (per store `apple.prebuild` / `play.prebuild`): `apple build` / `play build` run it first (printed, streamed, nonzero exit stops; `--dry` only prints; `--no-prebuild`; once per `flow run` through `STOREBOUND_PREBUILD_DONE`). After it, a Capacitor project's copied `public/index.html` must be newer than the last commit outside ios/ android/ node_modules/ store/ .storebound/ and `/index.html`, else the build stops ("web assets are older than the source ..."; `--allow-stale-web` overrides). `check` warns when stale without a prebuild; build output and dry plan show the bundle's sha256 + mtime. Monorepo (`apps/native` with its own capacitor.config.ts, android/, ios/; `play build` failed with "no Gradle project ... in android or ."): the one Capacitor project one or two levels down is found and named, several ask for `play.build.dir` / `apple.build.*` (`cli/lib/web-assets.ts`, `tests/web-assets.test.ts`). | | 22 | 2026-10 | `play build` built a good AAB (Capacitor 8 / AGP), then failed its own check with "AAB manifest without package / versionCode". The aapt2 proto manifest was read with element field numbers one too high (name 4, attribute 5, child 6 instead of Resources.proto's 3, 4, 5). The test fixture came from the same wrong encoder, so the tests passed. | `aabInfo` / `encodeProtoManifest` in `cli/lib/gradle.ts` follow Resources.proto. `tests/build-sign.test.ts` builds the manifest bytes by tag, without the encoder, and the parser was checked against real AGP bundles. Lesson: a parser's fixture must not come from the same code's encoder. | diff --git a/docs/ship-step.md b/docs/ship-step.md index cb44b15..c26809f 100644 --- a/docs/ship-step.md +++ b/docs/ship-step.md @@ -14,6 +14,7 @@ commands below keep their normal behaviour in a terminal; the contract switches | `storebound apple upload` | `upload` | `apple` | | `storebound apple submit` | `submit` | `apple` | | `storebound apple resubmit` | `resubmit` | `apple` | +| `storebound apple release` (`--manual-release`) | `release` | `apple` | A write still needs `--yes` in a pipeline: without it the confirmation is declined and the run ends with exit 3 (`CONFIRMATION_REQUIRED`). `apple submit` without `--yes` is a dry run (exit 0, `state: "planned"`). @@ -25,7 +26,7 @@ A write still needs `--yes` in a pipeline: without it the confirmation is declin | 0 | ok | - | | 1 | failure (build failed, network, timeout, blockers) | `FAILED`, `TIMEOUT`, `BLOCKERS` | | 2 | usage (unknown flag, bad value, bad `--version-from` / `--env-file`) | `USAGE`, `ENV_FILE` | -| 3 | needs the owner | `AGREEMENT_PENDING`, `SIGN_IN_REQUIRED`, `COMPLIANCE_ANSWER_MISSING`, `CONSOLE_STEP`, `CONFIRMATION_REQUIRED` | +| 3 | needs the owner | `AGREEMENT_PENDING`, `SIGN_IN_REQUIRED`, `COMPLIANCE_ANSWER_MISSING`, `CONSOLE_STEP`, `CONFIRMATION_REQUIRED`, `OWNER_STEP` | | 4 | the store refused it (4xx answer, ITMS error, invalid build, rejected upload) | `STORE_REFUSED`, `BUILD_INVALID` | | 7 | nothing to do: the build / release is on the store already (the result names what is there) | - | @@ -37,6 +38,31 @@ Console API access, Data safety, Content ratings, Publishing overview). The link Note: `apple upload` and `apple testflight wait` keep their old exit 7 for a processing timeout when the contract is off; under the contract a timeout is exit 1 `TIMEOUT` and an invalid build exit 4 `BUILD_INVALID`. +## Updates and first releases + +The production and review steps (`play upload --track production`, `play release --production completed|inProgress`, +`apple submit`, `apple resubmit`, `apple release --manual-release`) read from the store whether this is an **update** of +an app that is already live or its **first release** (Play: the production track holds a completed / inProgress / halted +release; App Store: a version of the platform is or was on sale; `cli/lib/release-kind.ts`). The result carries +`releaseKind`. + +- Update: `--yes` is all it takes. No owner gate, no bypass mode needed ([docs/bypass.md](bypass.md)). +- First release with `--yes`: exit 3, `error.code` `OWNER_STEP`, message "first release of this app ... the owner + approves it once", `error.consoleUrl` the page where the owner acts, nothing written. After the owner's yes: re-run + with `--first-release` (or bypass mode on). +- Already on the store (the versionCode on production with that status, the version submitted or released): exit 7, + nothing sent. + +```sh +# Android production update +storebound play release build/android/app.aab --production completed --yes --json --result-file out/release.json --env-file /run/secrets/ship.env +# (or a staged rollout: --production inProgress --fraction 0.05 --yes) +# iOS update: attach the uploaded build, submit, and later release the approved version +storebound apple submit --build 42 --yes --json --result-file out/submit.json --env-file /run/secrets/ship.env +storebound apple release --manual-release --yes --json --result-file out/release.json --env-file /run/secrets/ship.env +case $? in 0|7) ;; 3) echo "owner: $(jq -r .error.consoleUrl out/release.json)"; exit 1 ;; *) exit 1 ;; esac +``` + ## `--json`: one final line stdout carries exactly one line, at the end, also on failure: @@ -104,6 +130,7 @@ Reads the version and build for the step. Accepted shapes: - `play release`: every track already holds the same release: exit 7, nothing committed. - `apple submit`: the version is already submitted (or further) with this build, or the submission is in review: exit 7. +- `apple release --manual-release`: the version is released (`READY_FOR_SALE` ...) and none waits for release: exit 7. ## `storebound status` diff --git a/docs/store-art.md b/docs/store-art.md index 3ed9fba..35a7a05 100644 --- a/docs/store-art.md +++ b/docs/store-art.md @@ -82,8 +82,7 @@ languages, every store size, a review gallery and an app preview video. No store Play takes a YouTube URL in `play.video`. 7. **iPhone Duo (foldable).** `apple.iphoneDuo: true` renders and checks the outer (1398 x 2034) and inner (2007 x 2853) sets. Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds (`check` / `validate` - note it). The API has no Duo display type yet, so `apple screenshots` skips them with a link to the console's - iPhone Duo tab. Play has no foldable slot: foldables use the 7" / 10" tablet sets. + note it). `apple screenshots` uploads them through the App Asset Library (display class IPHONE_DUO). Play has no foldable slot: foldables use the 7" / 10" tablet sets. Details: the [store-screenshots](../skills/store-screenshots/SKILL.md) and [app-previews](../skills/app-previews/SKILL.md) skills. diff --git a/gemini-extension.json b/gemini-extension.json index 16d0c93..112ed09 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.47", + "version": "0.8.77", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index c9c6de6..a62d97e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.47", + "version": "0.8.77", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", diff --git a/skills/appstore-first-release/SKILL.md b/skills/appstore-first-release/SKILL.md index edb266a..e41c8c5 100644 --- a/skills/appstore-first-release/SKILL.md +++ b/skills/appstore-first-release/SKILL.md @@ -63,7 +63,12 @@ answers the compliance facts no file proves, all in one batch at the start. 10. **API · Review details.** `storebound apple review-details` (contact from `.storebound/credentials.json` `reviewContact`, or `--contact-from `; notes from the config). Apps behind a sign-in: the demo account from `.storebound/` (`review-access` skill). Apps with accounts: in-app deletion - (`account-deletion` skill). + (`account-deletion` skill). An app with an iOS **and** a macOS version: `--platform macos` (`apple listing`, `apple + review-details`) uses `apple.macos.listing` (its own appstore file) and `apple.macos.reviewNotes` when set, else + the iOS ones; the Mac app often has no Game Center, IAP or ads, so say so there. `storebound validate --platform + macos` warns about iOS-only claims in the Mac texts and notes (Game Center unless `apple.macos.gameCenter: true`, + tap / swipe / pinch / touch screen, iPhone / iPad, and IAP lines when `apple.macos.noIap: true`). Not `apple.mac` + (that only turns Mac screenshots on). 11. **API · In-app purchases and subscriptions.** `store-iap-setup` and `store-subscriptions` skills. First IAPs and subscriptions go to review **with** this version: `storebound apple iap-submit --with-version --dry`, then (after the owner's yes, since it submits) `storebound apple iap-submit --with-version --yes`. On a real first @@ -76,7 +81,7 @@ answers the compliance facts no file proves, all in one batch at the start. 14. **Submit.** `storebound apple submit --build ` (dry run: the plan table with the build you uploaded, by its number, the version and the ready IAPs in one review submission), then the owner's yes for the same command with `--yes`; with IAPs `apple iap-submit --with-version --yes` does the same without attaching a build (step 11). In - the desktop app the owner presses Releases > Submit for review (plan, then a system dialog). Release type MANUAL + the desktop app the owner presses Releases > Submit for review (plan, then a system dialog). Release type MANUAL (or apple.releaseType: afterApproval | manual | scheduled; `apple release --after-approval | --manual | --scheduled ` changes it until the version is approved; for the first version of an app after-approval is the owner's step) means the owner presses "Release" after approval. 15. **Verify + wait.** `storebound apple status`: version WAITING_FOR_REVIEW, IAPs WAITING_FOR_REVIEW, one review submission. `storebound apple status --wait-review` polls until approved (exit 0) or rejected (exit 1, diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index e54b8e9..b8f6d47 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -19,7 +19,7 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ 1. **Read.** `storebound apple signing status`: is the bundle id registered (else `storebound apple bundle`), which capabilities it has vs `apple.signing.capabilities`, the distribution certificates of the team and whether this Mac holds a private key for one (a key file in `.storebound/signing/` or an identity in the keychain), App Store - profiles per bundle id (app + `apple.signing.extraBundleIds` for extensions) and whether Xcode has them installed. + profiles per bundle id (the app and every extension / widget / watch app / App Clip found in the Xcode project, or `apple.signing.extensions`; `extraBundleIds` adds more) and whether Xcode has them installed. `signing ensure` registers the extensions' bundle ids, enables the capabilities their entitlements need and (manual style) makes their profiles; an App Group not proven on the App ID (manual: the profile; automatic: the capability and the newest profile the API lists) is reported with the developer portal link and steps (the API cannot assign a group): `storebound console plan apple-app-group --bundle-id --lookup`, drive it, then `signing ensure --refresh-profile` and `console verify apple-app-group` (store-pitfalls 121). It also compares three places a capability must be and names what is missing where: the App ID, the config's `apple.signing.capabilities` and the iOS project's `.entitlements` (`aps-environment` = PUSH_NOTIFICATIONS, `com.apple.developer.game-center` = GAME_CENTER, associated domains, Sign in with Apple, iCloud, app groups, ...; @@ -57,7 +57,18 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ number**: later steps use exactly it. Any archive or export failure exits non-zero, so `apple build && apple upload` stops there; an export failure shows the IDEDistribution log's real error lines. xcodebuild runs with Apple's tool folders first on PATH (a Homebrew rsync breaks Xcode's IPA step: "exportArchive Copy failed"; `storebound doctor` - flags it). + flags it). Over SSH or in CI (no GUI session) codesign fails with errSecInternalComponent on a locked keychain: + `apple build` stops first and names the fixes. `STOREBOUND_KEYCHAIN_PASSWORD=... storebound apple build + --keychain-unlock` unlocks the login keychain and sets its key partition list (the password is read from the + environment and never printed); `--keychain ` signs with a build keychain (`--keychain --keychain-unlock` + unlocks that one; `signing ensure --keychain ` imports the key + certificate into it). It + also stops before the archive when a target's App Group is missing (manual: its installed profile; automatic: the profile + the API lists; store-pitfalls 121; `--allow-app-group-gap` overrides). + `--out ` also puts DerivedData in `/DerivedData` (`--derived-data ` / `apple.build.derivedData` + overrides), so a build is hermetic per output folder. Building under a shared folder, or anything a privacy gate scans: + add `--path-free` (`apple.build.pathFree`): Swift and clang prefix maps keep the home folder, user name and checkout + out of the binary and the archive's dSYMs / Symbols, and the build stops before the export on a leftover hit + (store-pitfalls 124). `--scan-paths` scans without remapping. - Capacitor: the archive ships the web assets `npx cap sync` copied last. Set `build.prebuild` (e.g. `"npm run build && npx cap sync"`; per store `apple.prebuild` / `play.prebuild`) and `apple build` / `play build` run it first (once per `flow run`; `--no-prebuild` skips it). Then the copied `public/index.html` must be newer @@ -101,6 +112,13 @@ when the owner said so for this run). The MCP server offers only `apple_signing_ the generated entitlements (app sandbox, application / team identifier, your own keys kept), the embedded profile, hardened runtime and `bundleVersion`. Set `bundle.category` and `bundle.macOS.minimumSystemVersion` in tauri.conf.json (the build stops without LSApplicationCategoryType / LSMinimumSystemVersion). + A project with a Developer ID and a Mac App Store channel keeps two plists: `--tauri` takes the folder or a + `tauri..conf.json` overlay (merged over tauri.conf.json like Tauri's `--config`, and passed to `tauri build` + too; `tauri.appstore.conf.json` / `tauri.mas.conf.json` next to tauri.conf.json is picked up on its own). The + entitlements plist comes from `--entitlements `, else `apple.macos.entitlements`, else the merged + `bundle.macOS.entitlements`; the plan shows the source and the final keys. `get-task-allow` and + `cs.disable-library-validation` stop the build (App Store Connect refuses them); other hardened-runtime exceptions + (`cs.allow-jit`, `cs.allow-unsigned-executable-memory`, `automation.apple-events`...) warn. `--allow-entitlements` builds anyway. 5. Checks of the .app (sandbox, no get-task-allow, profile inside, version / build), `codesign --verify`, then `productbuild --component /Applications --sign ` and `pkgutil --check-signature`. 6. `--path-free`: rustc remaps the checkout, CARGO_HOME, RUSTUP_HOME and the home folder, no debug info, symbols stripped; diff --git a/skills/console-autofill/SKILL.md b/skills/console-autofill/SKILL.md index 251dac8..29a198d 100644 --- a/skills/console-autofill/SKILL.md +++ b/skills/console-autofill/SKILL.md @@ -45,7 +45,7 @@ the UI shows. This is the primary mode. `--all` / MCP `all: true` puts every card's steps inline in fix order and ends with the one owner list: tick the IARC Terms of Use box (before), Send changes for review (after)), 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` and `play-publish-changes` (managed publishing). `storebound console plan` lists them. + `play-production-countries`, `play-production-release` and `play-publish-changes` (managed publishing), and the Apple developer portal step `apple-app-group` (assign an App Group to an extension's App ID, which the API cannot do; its final Save is yours, `buttons.finalByAgent`; `console verify apple-app-group` reads the API). `storebound console plan` lists them. 2. Send the owner the `ownerBatch` of every form you will fill in ONE message before you start (AGENTS.md rule 5): the `ask-owner` answers (write them into the config, so the plan has them; never guess them) and the `before` steps (Play content rating: tick the IARC Terms of Use box on the questionnaire's page 1 first; page 1's Next does diff --git a/skills/play-first-release/SKILL.md b/skills/play-first-release/SKILL.md index 393544a..894b677 100644 --- a/skills/play-first-release/SKILL.md +++ b/skills/play-first-release/SKILL.md @@ -87,7 +87,9 @@ products) and answers the compliance facts no file proves, all in one batch at t new app shows "Not selected": set `play.category` (Play's name, e.g. "Strategy"; `storebound check` warns without it), then `storebound console plan play-store-settings`: /store-settings > the FIRST Edit > App or game > the Category dropdown's `[role=option]` with exactly that name > Save. Contact details come from `play listing`. -12. **API + UI · Production.** `storebound play release --version-code N --production draft`, then in the Console +12. **API + UI · Production.** The first-ever production release is the owner's (`storebound play release ... --production + completed --yes` on a never-published app is exit 3 `OWNER_STEP`, `--first-release` after the owner's yes; once the app is published every later version is an + update the agent ships itself, `release-management`). `storebound play release --version-code N --production draft`, then in the Console (you, with your browser tool): a) `storebound console plan play-production-countries`: tracks/production > Countries / regions tab > Edit countries / regions > the mat-checkbox "Select all rows" (or `play.countries`) > Save; a "Go to Publishing diff --git a/skills/release-flows/SKILL.md b/skills/release-flows/SKILL.md index 266e326..b5dfb0a 100644 --- a/skills/release-flows/SKILL.md +++ b/skills/release-flows/SKILL.md @@ -18,7 +18,7 @@ shell after the dry run). ```yaml description: Build, upload and check one version on both stores params: - VERSION: "1.0.0" # flow run release VERSION=1.2.0 overrides + VERSION: "?" # "?" = required: flow run release VERSION=1.2.0 (no silent default; --resume reuses the last run's) steps: - id: next-build if: apple # apple | play | exists | ${VAR} | !${VAR} | ${A} == x; && joins @@ -36,6 +36,10 @@ before: [] # hooks: before / after the steps, onError when a after: [] onError: [] ``` +`${config.testflight.internal}` is filled by the runner with the internal groups of `apple.testflight.groups` (empty when +none); the beta template uses it when `GROUP` is empty. The release template has optional steps by param: `MAC_TAURI=` +adds the macOS build + upload, `PLAY_TRACK=beta` uploads to Play open testing (default `internal`); its `promote` step is an +owner step listing the Play promotion. `continueOnError: true` lets a step fail without stopping the flow. A web build before the native builds (Capacitor: `npm run build && npx cap sync`) belongs in `build.prebuild` in the config rather than an `sh:` step: `apple build` and `play build` run it themselves, and within one `flow run` it runs @@ -44,7 +48,8 @@ once (the second build step prints "prebuild skipped (already ran in this flow r ## Modes - `flow run release VERSION=1.2.0` (default): read steps (check, validate, scan, release plan, next-build, status ...) run for real; **every write step runs with `--dry`**; wait steps check once; shell steps are skipped. Nothing - changes. Show this output to the owner. + changes. Show this output to the owner. A dry `apple build` / `play build` writes no IPA / AAB, so the upload after it + (and `apple testflight wait` / `distribute` after that) is shown as "planned" instead of failing; a real run is unchanged. - `--dry`: the plan only, nothing runs. - `--apply`: writes and shell steps run for real; each write command still asks its own confirmation in the terminal. Never add `--yes` to a step (flow validate refuses it). diff --git a/skills/release-management/SKILL.md b/skills/release-management/SKILL.md index c5dded7..34f7a7f 100644 --- a/skills/release-management/SKILL.md +++ b/skills/release-management/SKILL.md @@ -14,8 +14,8 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately `--versions` lists every version the stores know. The same stages as the desktop's Releases tab. - `storebound release plan 1.3.0` (MCP `release_plan`, read-only): what is left for that version on each store, in fix order: config problems (`check`), the version on the store, store blockers (`validate`), the next move, warnings. - Owner steps (submit, release, raise / complete a rollout) are named with where the owner does them; you never run - them. "could not be read" means unknown, not "not there": fix the credentials first. + Owner steps (the first-ever submit / release of an app) are named with where the owner does them; updates of an app + on sale (submit, release, raise / complete a rollout) are yours, `--yes`. "could not be read" means unknown, not "not there": fix the credentials first. - Many apps (a `storebound.workspace.json` above the app folders): the same commands cover every app, or `--app a,b`. `storebound ws status` is the portfolio table across apps and stores. @@ -31,20 +31,31 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately teammate may have uploaded another one meanwhile. 2. `storebound apple release --phased on --dry`, then without `--dry` (works while the version is being prepared). With versions on more than one App Store platform add `--platform ios|macos|tvos|visionos` (pitfall 79). -3. Submit (owner, UI). Wait: `storebound apple status --wait-review` (polls with backoff, exit 0 approved, 1 rejected, +3. Submit: an update of an app on sale is yours, `storebound apple submit --build --yes` (exit 7 when it was sent + already); the first-ever submission is the owner's (exit 3 `OWNER_STEP`, re-run with `--first-release` after the + owner's yes). Wait: `storebound apple status --wait-review` (polls with backoff, exit 0 approved, 1 rejected, 7 timeout; `--timeout 72h`). Rejected: `review-rejections` skill. -4. Approved = PENDING_DEVELOPER_RELEASE. Owner releases: `storebound apple release --manual-release` (asks to confirm) - or the "Release This Version" button. With phased release on, automatic updates reach 1 %, 2 %, 5 %, 10 %, 20 %, +4. Release type: an update that should go live by itself is `afterApproval`. Set `apple.releaseType: afterApproval` in the + config (`apple listing` applies it to a new version, `apple submit` sets it before submitting and its dry run shows + `releaseType MANUAL -> AFTER_APPROVAL`), or on one version `storebound apple release --after-approval --yes` + (`--manual` / `--scheduled ` are the other two; allowed until App Review approves the version, exit 7 when it + is that type already; pitfall 125). Without it an approved MANUAL version waits in PENDING_DEVELOPER_RELEASE. + Approved = PENDING_DEVELOPER_RELEASE. Release an update yourself: `storebound apple release --manual-release --yes` + (the first-ever release of an app is the owner's: `OWNER_STEP`) or the "Release This Version" button. With phased release on, automatic updates reach 1 %, 2 %, 5 %, 10 %, 20 %, 50 %, 100 % over 7 days; anyone can still update manually from the store. 5. During the rollout watch crashes / reviews. Problem: `storebound apple release --phased pause` (up to 30 days in total). Pausing stops automatic updates only; it is **not a rollback**: users who updated keep the build, and new users still get the new version. The fix is a new build (expedited review is possible for critical bugs). -6. All good early: `storebound apple release --phased resume` / `--phased complete` (owner; asks to confirm). +6. All good early: `storebound apple release --phased resume --yes` / `--phased complete --yes` (an update's rollout is + yours). ## Google Play 1. `storebound play next-version-code` before building; the versionCode must be higher than every uploaded one. -2. Upload and start the rollout (owner confirms): `storebound play release app.aab --production inProgress --fraction 0.05`. - Draft apps cannot do this until the first release is reviewed (`play-first-release`). +2. Upload and start the rollout of an UPDATE (no owner step; the app is published): `storebound play release app.aab + --production inProgress --fraction 0.05 --yes` (`--production completed --yes` for everyone; or `storebound play + upload app.aab --track production --yes`). Re-running it is exit 7. The first-ever production release is the owner's + (exit 3 `OWNER_STEP`; after the owner's yes `--first-release`), and draft apps cannot do this until the first + release is reviewed (`play-first-release`). Changes made in the Console (listing, App content) are sent from Publishing overview > "Submit N changes for review". While a review is running that button opens "Do you want to restart your review?" with **Restart review**: the running review starts over with the new changes. The owner's button, never the agent's; to keep the diff --git a/skills/store-analytics/SKILL.md b/skills/store-analytics/SKILL.md index 2598455..0cd275c 100644 --- a/skills/store-analytics/SKILL.md +++ b/skills/store-analytics/SKILL.md @@ -16,7 +16,8 @@ CLI: `storebound analytics setup | snapshot | compare`. MCP: `analytics_snapshot (`stoppedDueToInactivity`); `setup` makes a new one. storebound reads "App Store Discovery and Engagement" (Event Impression / Page view, Counts and Unique Counts) and "App Store Downloads" (First-time Download, Redownload; updates are left out), checks each segment's md5 checksum and keeps, per day, the newest processing of that day. - Conversion = (first-time downloads + redownloads) / unique impressions. Source: + Conversion = (first-time downloads + redownloads) / unique impressions. App Usage (sessions, active devices, crashes) comes from the + "App Sessions" and "App Crashes" reports of the same request. Source: https://developer.apple.com/documentation/appstoreconnectapi/downloading-analytics-reports - **Google Play**: there is **no API** for store listing statistics (the Play Developer Reporting API only has vitals). Play Console writes monthly CSV exports into your own Cloud Storage bucket; storebound reads them with @@ -28,6 +29,29 @@ CLI: `storebound analytics setup | snapshot | compare`. MCP: `analytics_snapshot results (read them in the consoles; `storebound apple ppo status` shows the App Store test state), Play "Explore" search terms beyond the traffic source file. +## For a collector (store-only config, JSON on stdout) +A script that reads many apps needs no project: a config with `apple.appId` (`bundleId` null or left out) and +`play.packageName` is valid for `analytics` and `reports` reads (every write still needs the bundle id). +- `storebound analytics snapshot --store apple|play --days 30 --config --account --json` + prints the snapshot as one JSON document on stdout (logs go to stderr); the file in `--dir` is still written, add + `--no-write` to skip it. A store that failed carries `{ "error": ... }`; when every store failed the JSON is printed + and the exit code is 1. Non-fatal gaps are in `notes`. +- Play bucket without `play.reportsBucket`: env `PLAY_REPORTS_BUCKET`, else the credential profile's `reportsBucket` + (a bucket name, not a secret). Same for `reports play-*`. +- Active users, per day. App Store: `sessions`, `activeDevices`, `sessionSeconds`, `crashes` from the App Usage + reports (App Sessions, App Crashes; `activeDevices` is the sum of the rows' Unique Devices, so a device seen in two + rows, e.g. two app versions, counts twice; totals: `sessions`, `crashes`, `activeDevicesAvg`). They appear a day or + two after the report request exists; until then the snapshot has the rest and a `notes` line. Play: `installs` (Daily + User Installs), `activeDeviceInstalls` (Active Device Installs) and `totalUserInstalls` (Total User Installs) from the + installs overview file (all countries; else the country file summed). The last two are stock numbers: totals take the + newest day, never a sum. +- Revenue per day: `storebound reports apple-sales --days 30 --json` (or `--from 2026-10-01 [--to 2026-10-25]`) reads + the daily SALES reports of the range (4 at a time): `days[]` with `summary` per SKU and currency and `totals` per + currency, `totals` for the range per currency, `missing` (days with no report: no sales, or not out yet; never an + error). `storebound reports play-sales --days 30 --json` groups the estimated sales by order charged date and + currency of sale: `orders`, `refunds`, `itemPrice`, `taxes`, `charged` (tax included), `refunded` (negative), + `net`, plus `emptyDays` and `missingMonths`. Play's earnings (proceeds after Google's fee) are monthly only. + ## Flow 1. Once: `storebound analytics setup` (App Store) and `play.reportsBucket` (Play). 2. Weekly: `storebound analytics snapshot` (default the last 28 days; `--dry` prints totals only). diff --git a/skills/store-api/SKILL.md b/skills/store-api/SKILL.md index dae8e94..74e61dc 100644 --- a/skills/store-api/SKILL.md +++ b/skills/store-api/SKILL.md @@ -42,7 +42,9 @@ storebound api apple salesReports_getCollection --param ... --out report.gz # on stderr (store-pitfalls 78). Never pipe or redirect a write; use the dedicated command the message names. An agent without a terminal sends a plain (non-owner) write with `--yes --confirm `, the id equal to the one the call resolved to (store-pitfalls 92), after showing the dry run. `--confirm` never applies to - owner-only operations. + owner-only operations. Both flags are needed, and `--confirm` repeats the operation id: + `storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance` + (without `--yes` it stays a dry run; a `--confirm` that differs from the id is refused). 3. Owner-only operations (cli/api/gate.ts: every DELETE, submissions and their cancel, release requests, phased release changes, product page test start / stop, price schedules and price migrations, review replies, tester and user invitations, Play `edits.commit`, product activation, refunds, revokes and cancels, app signing) also ask for diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 15a874c..956e371 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1229,20 +1229,31 @@ that fails without the fix (see CONTRIBUTING.md). (and read relative to the working directory, not the project). - **Do:** run the build in the Mac's logged-in session: a CI agent started there, or from SSH a `.command` file opened with `open -a Terminal ` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). `--allow-ssh` after - `security unlock-keychain` skips the check. -- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive; `credPath` in + `security unlock-keychain` skips the check. To build over SSH or in CI anyway: export `STOREBOUND_KEYCHAIN_PASSWORD` + (never printed, never an argument of storebound) and add `--keychain-unlock` (unlocks the login keychain and runs + `security set-key-partition-list -S apple-tool:,apple:,codesign:`), or `--keychain --keychain-unlock` for a + build keychain that holds the Apple Distribution key + certificate (`apple signing ensure --keychain ` imports + them); `--keychain` also signs the archive with `OTHER_CODE_SIGN_FLAGS=--keychain ` and puts the keychain on the search list. +- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive, and its message names + these fixes (`sshBuildRefusal`, `cli/lib/keychain.ts`; `tests/signing-targets.test.ts`); `credPath` in `cli/lib/credentials.ts` resolves relative credential paths from the project and `ascKeyFile` hands xcodebuild an absolute path (`tests/build-sign.test.ts`). -## 102. A new Play language: `play listing` 403 "no high resolution icon set for language", `play images` skips it -- **Symptom:** adding a language (tr-TR) to an existing app, `play listing --yes` failed at the edit commit with 403 - "This app has no high resolution icon set for language tr-TR", while `play images` said "skipped tr-TR: no Play +## 102. A new Play language: commit 403 "no high resolution icon" / "too few screenshots for language", `play images` skips it +- **Symptom:** adding a language (tr-TR) to an existing app, `play listing --write --yes` created the listing and + uploaded the icon and feature graphic, then the commit failed: 403 PERMISSION_DENIED "This app has too few screenshots + for language tr-TR" (earlier: "no high resolution icon set"). `play images` then said "skipped tr-TR: no Play listing yet": each waited for the other. -- **Cause:** Play commits a new language only when its hi-res icon is in the same edit. -- **Do:** update storebound; `play listing` now uploads the icon (and the feature graphic when rendered) for a new - language in the same edit. Then run `play images` for the screenshots. -- **Guard:** `firstGraphics` in `cli/play/images.ts`, used by `cli/play/listing.ts` for languages Play does not have yet; - it stops with the fix when the 512x512 icon is missing. +- **Cause:** Play commits a new language only with its hi-res icon, feature graphic, 2+ phone screenshots (and the + tablet sets when the app's other languages have them) in the SAME edit. +- **Do:** update storebound; `play listing` uploads all of these for a new language in its edit, from + `store/renders//` (a set that folder lacks falls back to the default language's, with a note), and the dry run + lists the uploads or refuses with the exact missing file. `play images` now creates a listing Play does not have yet + (text from the listing file) together with its images, so either command works. Render first if it refuses: + `storebound shots render`. +- **Guard:** `newLanguageImages` in `cli/play/images.ts`, planned in `cli/play/listing.ts` before any write; + `FakePlay.requireNewLangAssets` simulates the 403 (`tests/idempotency.test.ts`). The first listing of an app with no + listing at all only warns. ## 103. "App.entitlements is not referenced by CODE_SIGN_ENTITLEMENTS" on Windows although it is - **Symptom:** on Windows, `apple status` / `apple signing` warned that `ios\App\App\App.entitlements` is not @@ -1454,3 +1465,164 @@ that fails without the fix (see CONTRIBUTING.md). attributes first, `text=` last); `shots capture --dry` prints them per size, `--dry --probe` opens the pages and names the MISS with the size and every candidate's match count. - **Guard:** `tests/shots-gaps.test.ts`, `tests/browser/feature-fit.browser.ts` (rotate), `cli/shots/selectors.ts`. + +## 121. An app extension (share extension, widget, watch app) could not be signed: bundle id not registered, capabilities missing, App Group not in the profile + +- **Symptom:** an iOS app with a share extension archived, then failed at export / codesign: "No profile for team 'X' + matching 'com.example.app.share' found", "Provisioning profile ... doesn't include the App Groups capability" or + "doesn't match the entitlements file's value for the com.apple.security.application-groups entitlement". `apple + signing ensure` knew only the app's bundle id and refused (`not registered`) for `extraBundleIds`. +- **Cause:** every signable target of the archive (app, app extension, widget, watch app, App Clip) is its own bundle id + with its own capabilities and profile. And Apple's API can switch the APP_GROUPS capability on for a bundle id but + cannot assign a group container to it: the profile carries `com.apple.security.application-groups` only after the + group was ticked in the developer portal (Identifiers > the bundle id > App Groups > Configure). +- **Do:** `storebound apple signing ensure --dry` lists every target found in the Xcode project (PRODUCT_BUNDLE_IDENTIFIER + and CODE_SIGN_ENTITLEMENTS of the Release configuration) and what it would register, enable and create; without + `--dry` it registers the extension bundle ids, enables the capabilities its entitlements need (the entitlement-to- + capability table; `keychain-access-groups` needs none) and, for manual signing, creates an App Store profile per + target. It then compares each target's App Groups with what the API shows (manual signing: the decoded profile; automatic + signing: the App ID's APP_GROUPS capability settings and the newest profile the API lists for the bundle id, an + Xcode-managed one too; a profile that carries no group, or the capability off, is a gap; nothing readable is + "cannot be proven" and only warns with the same steps): a gap fails with + the developer portal deep link `https://developer.apple.com/account/resources/identifiers/bundleId/edit/` and the + steps to tick the group (drive it in the signed-in browser; never press Remove / Delete / Revoke). After that + `storebound apple signing ensure --refresh-profile` makes the profile again (a normal run reuses the active one, so it + does not pile up profiles while the group is unassigned). `apple build` stops before the archive on the same gap in both + signing styles (manual: the installed profile; automatic: read through the API; `--allow-app-group-gap` overrides, e.g. + when the group was just ticked and Xcode will renew its managed profile at archive time). The agent-first plan is + `storebound console plan apple-app-group --bundle-id ` (`--lookup` adds the deep link; its Save is the agent's own, not a + store submission) and `storebound console verify apple-app-group` re-reads the App ID and the newest profile (after the + portal Save the old profiles are invalid: run `signing ensure --refresh-profile` first). A variable bundle id (set in an xcconfig) is not resolvable: list the + extension in `apple.signing.extensions` (`bundleId`, `capabilities`, `appGroups`), which replaces the detected ones. +- **Guard:** `cli/lib/targets.ts` (pbxproj targets, App Group comparison, portal link and steps), `discoverTargets` in + `cli/apple/signing.ts`, `tests/signing-targets.test.ts` (fixture pbxproj with an app, a share extension and app group + entitlements, a decoded profile plist, a fake App Store Connect), `cli/lib/console-apple-group-plan.ts` (the plan), + `verifyAppGroupsApi` in `cli/lib/console-verify.ts`, `appGroupVerdicts` in `cli/lib/targets.ts` (gap / unproven / ok). + +## 122. Google keys moved to a credential broker: tokens, fallback and missing grants + +- **Symptom:** after the Google service-account keys moved to the owner's credential broker, Play commands either + still wanted `PLAY_SERVICE_ACCOUNT`, or an upload ran its edit, uploaded and only then died on a bare 403 for a + permission the broker could have named; an error dump could carry the bearer token. +- **Do:** configure `~/.storebound/broker.json` (`url`, `apiKeyFile`, key file mode 600) and, when the account is not + the local key's, `PLAY_SERVICE_ACCOUNT_EMAIL` in the profile. `storebound doctor` says which source Play uses. + Before Play writes, the preflight reads `GET /api/google/play//storebound` and stops on a missing + permission or API with the exact name and link; grant it on the broker (`google_play_grant`) or enable the API + (`google_api_enable`), never by retrying. A 404 there means "register the package on the broker", not "no access". + A broker that refuses the API key (401 / 403) needs `google:read google:token` on the key; storebound neither retries + nor falls back then. The broker has no scope for the Reporting API or Play Games writes: those keep the local key. +- **Never:** write an access token to disk or into a log, put the broker key into a config or docs, or loop on a 403. +- **Guard:** `cli/lib/broker.ts` (config, key file mode, token cache and single flight, grants, preflight, redaction), + `cli/lib/google-auth.ts` `playToken` (broker first, local key fallback), `cli/lib/google-access.ts` (403 -> + GoogleAccessError, no retry), `tests/credential-broker.test.ts` (a local fake broker). + + +## 123. Updates are agent steps: a pipeline could not ship a production update without the owner + +- **Symptom:** a CI pipeline shipping a new version of an app that was long on the store was told that production is + "the owner's" (an AAB to production refused with "play upload is for testing tracks ... the owner sends it"), though + the owner's rule is that updates of a live app ship end to end without a confirmation per update. +- **Do:** an update is read from the store (Play: the production track holds a completed / inProgress / halted release; + App Store: a version of the platform is or was READY_FOR_SALE) and runs with `--yes`, in a terminal or a pipeline: + `storebound play release --production completed --yes` (or `--production inProgress --fraction 0.05`, or + `play upload --track production --yes`), `storebound apple submit --build --yes`, `storebound apple release + --manual-release --yes`; `--json` / `--result-file` give the ship-step result (`releaseKind: "update"`), a re-run is + exit 7. Only the first-ever release of an app is the owner's: exit 3 `OWNER_STEP` "first release of this app", approved + once (`--first-release` after the owner's yes, or bypass mode). Removal from sale and live prices keep their own gates. +- **Never:** tell the owner to press a button for an update, pass `--first-release` without the owner's yes, or decide + update-or-first from the config instead of the store. +- **Guard:** `cli/lib/release-kind.ts` (the kind from the store, the policy), `tests/update-gate.test.ts` (fake stores: + update goes through, first release is exit 3, already there is exit 7), docs/bypass.md "Updates are agent steps". + +## 124. iOS builds leak the home folder: DerivedData in ~/Library and local paths in the binary and the symbols + +- **Symptom:** a release pipeline building under a shared folder outside the user's home (`apple build --out `) + still wrote DerivedData to `~/Library/Developer/Xcode/DerivedData`, and the app binary and the `.xcarchive` + `Symbols/*.symbols` / dSYMs held the home folder, the user name and the checkout path (a privacy gate counted 543 hits). +- **Do:** `storebound apple build --out --path-free` (or `apple.build.pathFree: true`). With `--out` DerivedData is + `/DerivedData` (`--derived-data ` / `apple.build.derivedData` to choose); `--path-free` passes Swift + `-debug-prefix-map` + `-file-prefix-map` and clang `-ffile-prefix-map` for the home folder, checkout, out and DerivedData, + keeps dwarf-with-dsym (symbol upload still works) and scans the archive's Products, dSYMs and Symbols before the export. + A hit stops the build with the count and the first file + offsets. Xcode's own toolchain paths (`/Applications/Xcode.app`) + are not the user's and are not hits. A prebuilt vendored framework carries its own paths: rebuild it path-free. +- **Never:** point a shared build folder at the default DerivedData, or run the privacy scan only after the upload. +- **Guard:** `cli/lib/path-free.ts`, `tests/path-free.test.ts`, the hint when `--out` is outside the home folder. + +## 125. Updates sat in Pending Developer Release: the version was created and submitted with release type MANUAL + +- **Symptom:** an approved update did not go live; it waited in PENDING_DEVELOPER_RELEASE because `apple listing` created the + version with releaseType MANUAL and `apple submit` never touched it. +- **Do:** set `apple.releaseType: afterApproval` (or `manual` / `scheduled` with `apple.earliestReleaseDate`). `apple listing` + creates new versions with it, `apple submit` / `apple resubmit` set it on the version before submitting when it differs + (the dry run shows `releaseType MANUAL -> AFTER_APPROVAL`). On one version: `storebound apple release --after-approval --yes` + (`--manual`, `--scheduled `; `--platform`, `--version`, `--json`; exit 7 when it already has that type). The type + is editable until App Review approves the version; an approved one is released with `apple release --manual-release`. + Unset: new versions are MANUAL and existing ones are left as they are. Updates are agent steps; `--after-approval` on the + first version of an app puts it on sale by itself, so that is the owner's (exit 3 `OWNER_STEP`, `--first-release`). +- **Never:** wait for an approval email and then look for the console button, or flip the type back with `apple listing` runs. +- **Guard:** `cli/lib/release-type.ts`, `tests/apple-release-gaps.test.ts`. + +## 126. iPhone-only build of an app whose live version runs on iPad: ITMS-90101 after a full archive + +- **Symptom:** `storebound check` passed with `apple.ipad` false; the upload was refused after the archive with ITMS-90101 "This + bundle does not support one or more of the devices supported by the previous app version" (TARGETED_DEVICE_FAMILY = 1). +- **Do:** `storebound validate` and `apple build` read the live version (READY_FOR_SALE, else the newest that was) from App + Store Connect: its build bundle (`deviceProtocols`, device models of `buildBundleFileSizes`) and its screenshot sets + (`APP_IPAD_*`); any iPad signal means iPad. They compare it with TARGETED_DEVICE_FAMILY of the Release configuration in the + Xcode project and with `apple.ipad`, and stop BEFORE the archive with the fix: `TARGETED_DEVICE_FAMILY = "1,2"` and + `apple.ipad: true`. Offline `check` warns when the live families cannot be known. `--allow-device-gap` builds anyway. +- **Never:** drop a device family the live version supports, or turn `apple.ipad` off to silence the screenshot check. +- **Guard:** `cli/lib/device-families.ts`, `cli/apple/device-families.ts`, `tests/apple-release-gaps.test.ts`. The build bundle + attribute names come from Apple's spec; which signal carries the iPad answer live is not recorded, hence the merge. + +## 127. TestFlight invite on a new external group: 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS + +- **Symptom:** `apple testflight testers --group --invite --yes` created the tester but the invitation failed with 409 + NO_INSTALLABLE_BUILDS: the group had no build past Beta App Review yet. +- **Do:** `--invite` first checks that a build of the tester's group is installable (external: BETA_APPROVED / IN_BETA_TESTING; + internal: READY_FOR_BETA_TESTING / IN_BETA_TESTING). If not, the tester stays added, no invitation is sent and the run prints + "tester added; the invite goes out when a build in passes Beta App Review (re-run with --invite then)", exit 0. The + 409 itself is treated the same way. `apple testflight distribute --submit-review` prints the line for testers not invited yet. +- **Never:** retry the invitation in a loop, or report the added tester as a failure. +- **Guard:** `installableBuild` / `noInstallableBuildHint` in `cli/lib/testflight.ts`, `tests/apple-release-gaps.test.ts`. + +## 128. macOS submission of a two-platform app planned the iOS in-app purchases and Game Center + +- **Symptom:** `apple resubmit --platform macos` (and `apple submit`) planned every consumable of the config and a + `gameCenterAppVersions` write for a Mac app that sells nothing and has no Game Center. +- **Do:** set `apple.macos.noIap: true` and leave `apple.macos.gameCenter` unset; a macOS submission then carries the + version and build only, and the dry run names what it left out and why. +- **Never:** attach iOS-only products to the Mac submission or switch Game Center on for a Mac app without it. +- **Guard:** `macosSubmitSkips` in `cli/lib/config.ts`, `tests/resubmit.test.ts`. + +## 129. Play said "In review" while an old testing build blocked Send changes for review + +- **Symptom:** `play status` showed production as "In review" for days; Publishing overview had "Send changes for + review" greyed out. An old build still active on a testing track (alpha) asked for READ_MEDIA_IMAGES / + READ_MEDIA_VIDEO ("Use alternative system pickers"); the current production build did not. Play reviews every active + build, so the policy issue of the old one held the whole app back. A later commit with `--not-sent-for-review` + answered 400 "Changes are sent for review automatically" (managed publishing off). +- **Do:** replace each old build: `storebound play release --version-code --alpha completed` (or + `--beta`, `--internal`, `--track `); the changes then go into review by themselves. `play status` and + `validate` list every active build below production with that command. +- **Never:** read a completed production release that the store page does not show as "In review": the API has no + review state, so storebound says "Not live yet: in review or blocked before review". +- **Guard:** `cli/play/old-builds.ts` (play status, validate `old-builds`), `PLAY_NOT_LIVE` in `cli/lib/portfolio.ts`, + `isAutoSendOnly` in `cli/play/review-pending.ts` (the commit is redone without changesNotSentForReview), + `tests/play-old-builds.test.ts`. + +## 130. iPhone Duo screenshots were skipped as "not in the API" + +- **Symptom:** `apple screenshots` skipped the iPhone Duo sets with "the API has no iPhone Duo display type yet". + Wrong: ScreenshotDisplayType never gets a Duo value. API 4.5.1 put the display class `IPHONE_DUO` into the App Asset + Library (AppAssetLibraryDisplayClass) and deprecated appScreenshotSets / appScreenshots / appPreviewSets / + appPreviews in the same release. +- **Do:** `storebound apple screenshots` uploads Duo (apple.iphoneDuo, renders iphone-duo-outer/ then + iphone-duo-inner/) through the library: `appAssetLibraryRefData` (filter placementTypes APP_SCREENSHOT) gives the + iOS placementProfileGroup of IPHONE_DUO, files go up as appAssetLibraryImages (category + APP_SCREENSHOTS_AND_PREVIEWS), then appAssetLibraryPlacements (APP_SCREENSHOT, that placementGroup, the version + localization). Same MD5 reuse as creative assets; read back. +- **Never:** say an API lacks a store feature after checking one enum: search the whole spec (every schema with the + feature's name) first. +- **Guard:** `cli/apple/duo-library.ts` (duoGroups, syncDuo), `duoDirs` in `cli/apple/screenshots.ts`, + `tests/ppo.test.ts`. Previews and the other display classes still use the deprecated sets until they move too. diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index c2bccb1..f0d9a5d 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -71,10 +71,9 @@ in a plain Android tablet body instead of the iPad one. outer (1398 x 2034) and an inner (2007 x 2853) set, portrait or landscape, PNG / JPEG without alpha, 1-10 each. It is optional now and reportedly required from April 2027 for builds with the iOS 27.1 SDK or later; `check` / `validate` print an info note until then (a warning after, never a blocker). `apple.iphoneDuo: true` adds both sizes to -`shots render` / `shots check` / the gallery. The API (4.5.1) has no Duo `ScreenshotDisplayType` yet, so -`apple screenshots` skips the Duo folders with a message and the deep link to the version page; add them in the -console's iPhone Duo tab until then (one constant, `IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts`, takes the names once -the enum has them). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the +`shots render` / `shots check` / the gallery. ScreenshotDisplayType has no Duo value (and won't get one), so +`apple screenshots` uploads the Duo folders through the App Asset Library (display class IPHONE_DUO, placement +APP_SCREENSHOT; pitfall 130). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the 7" / 10" tablet sets below. **Mac App Store.** Apple's Screenshot specifications (checked 2026-10-20) list four 16:10 Mac sizes: 1280 x 800, @@ -83,7 +82,12 @@ the enum has them). No Duo app preview sizes are published. Google Play has no f alpha) and requires the set, and `apple screenshots --platform macos` uploads it as `APP_DESKTOP` to the macOS version (versions are per platform, pitfall 79; the iOS run uploads the iOS sets only and says where the Mac set goes). `validate` prints an info note when the app has a macOS version but no Mac screenshots. Frames: landscape -captures in `//landscape/.png`, `shots.landscape.size` CSS px (default 1440 x 900 @2x). +captures in `//landscape/.png`, `shots.landscape.size` CSS px (default 1440 x 900 @2x). Make them with +`storebound shots capture --device landscape` from a web build (a desktop viewport, no touch, no status bar crop); with +`apple.mac`, `play.chromebook` or `shots.landscape.playTablets` the default `capture` run adds the landscape device and +captures every shot with no `device` (and `both`) for it too, because render needs a landscape frame per scene; a shot +with `device: 'landscape'` is landscape only, `'phone'` / `'tablet'` stays out. `ios-sim` / `android-emu` have no +landscape device (skipped with a warning): use a web build or the folder source. **Landscape layout (Mac, Chromebook, Play tablets).** Copy in a column on one side (`shots.landscape.side`, default left), the scene's first frame on the other. `shots.landscape.frame`: `'window'` draws a Mac window (title bar with @@ -119,6 +123,8 @@ the icon, feature graphic and phone screenshots and prints `tablet screenshots: `///.png`, local, not committed. - `web` (default): `baseUrl`, `query` with `{lang}`, shots: `path` / `waitFor` / `eval` / `delayMs` / `device`. Phone 440 x 902 @3x and tablet 800 x 1240 @2x by default; the template draws the status bar above. + Landscape (Mac / Chromebook): `--device landscape` or `device: 'landscape'` per shot, a desktop viewport at + `shots.landscape.size` (default 1440 x 900 @2x), `waitSelector` / `click` take a `landscape` list too. Speed (many languages): frames run in parallel browser contexts (`--concurrency N`, `capture.concurrency`; default min(3, cores / 4) since game boots are GPU-heavy). `capture.session: 'per-language'` boots the app once per language and device and switches shots in that page (per shot `navigate: 'spa'` = pushState + popstate to its diff --git a/skills/testflight/SKILL.md b/skills/testflight/SKILL.md index 42aadae..f076722 100644 --- a/skills/testflight/SKILL.md +++ b/skills/testflight/SKILL.md @@ -39,7 +39,10 @@ changes or deletes a group. commits; storebound masks them in every log line (`j***@example.com`). - `storebound apple testflight testers --dry` shows: new testers, testers added to more groups, (with `--remove`) testers who leave a managed group. The real run **asks first**: Apple emails each new tester an invitation once the - group has a build. `--invite` (re)sends invitations to testers still `NOT_INVITED` / `INVITED` (asks again). + group has a build. `--invite` (re)sends invitations to testers still `NOT_INVITED` / `INVITED` (asks again) once a build of the tester's group is + installable (an external group: past Beta App Review). Without one the tester is added, the invite is skipped and the run + says "tester added; the invite goes out when a build in passes Beta App Review (re-run with --invite then)" (exit 0; + Apple's 409 `NO_INSTALLABLE_BUILDS` is handled the same way; `distribute --submit-review` prints the same line; pitfall 127). - Internal groups only take App Store Connect users (Users and Access); add people there first. - Testers are an owner action: not an MCP tool. An agent prepares the CSV format and the dry run, the owner runs it. diff --git a/templates/storebound.config.ts b/templates/storebound.config.ts index 91a5bab..b25122e 100644 --- a/templates/storebound.config.ts +++ b/templates/storebound.config.ts @@ -51,6 +51,7 @@ const config: ShipConfig = { // usesNonExemptEncryption: false, // ipad: true, // the app runs on iPad: 13" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded // iphoneMedium: true, // optional 6.3" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5" set down + // releaseType: "afterApproval", // afterApproval | manual | scheduled (+ earliestReleaseDate): how an approved version goes live; unset = new versions MANUAL // iphoneDuo: true, // optional iPhone Duo (foldable) sets, outer 1398 x 2034 + inner 2007 x 2853; rendered and checked, not uploaded until the API takes them // screenshotTypes: ['APP_IPHONE_61'], // what `storebound validate` requires per locale (default: medium display, met by a 6.9" or 6.5" set; + APP_IPAD_PRO_3GEN_129 with ipad) // TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only. diff --git a/templates/workflows/beta.yml b/templates/workflows/beta.yml index 6049b2e..2dfe6f1 100644 --- a/templates/workflows/beta.yml +++ b/templates/workflows/beta.yml @@ -1,9 +1,11 @@ # storebound flow "beta" for a game: a playtest build on TestFlight and Play internal testing. # storebound flow run beta VERSION=1.2.0 [--apply] +# VERSION is required ("?"). GROUP empty = the internal group of apple.testflight.groups (the group `internal: true`); +# GROUP= picks a TestFlight group that exists (or is listed in apple.testflight.groups). description: Next game build to TestFlight and Play internal testing params: - VERSION: "1.0.0" - GROUP: "Playtesters" + VERSION: "?" + GROUP: "" steps: - id: check run: check @@ -25,8 +27,14 @@ steps: if: apple run: apple testflight wait --build ${BUILD} --version ${VERSION} - id: distribute - if: apple + if: apple && ${GROUP} run: apple testflight distribute --build ${BUILD} --group ${GROUP} + - id: distribute-internal + if: apple && !${GROUP} && ${config.testflight.internal} + run: apple testflight distribute --build ${BUILD} --group ${config.testflight.internal} + - id: no-group + if: apple && !${GROUP} && !${config.testflight.internal} + owner: "No TestFlight group: pass GROUP= or add a group with internal: true to apple.testflight.groups, then storebound apple testflight distribute --build ${BUILD} --group " - id: play-build if: play run: play build --version-name ${VERSION} diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml index f73ecca..13d9d79 100644 --- a/templates/workflows/release.yml +++ b/templates/workflows/release.yml @@ -3,10 +3,13 @@ # storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first # storebound flow run release --resume --apply continue after fixing a failed step # Steps are storebound commands (no "storebound" prefix). `if: apple` / `if: play` skip a store the config does not -# have; `owner:` steps are never run, they list what you click. `sh:` runs your own build command (only with --apply). +# have; `if: ${PARAM}` turns an optional step on by a param (MAC_TAURI= adds the macOS build, PLAY_TRACK=beta +# uploads to Play open testing); VERSION is required; `owner:` steps are never run, they list what you click. `sh:` runs your own build command (only with --apply). description: Build, upload and check one game version on both stores, then your Submit click params: - VERSION: "1.0.0" + VERSION: "?" + MAC_TAURI: "" + PLAY_TRACK: "internal" steps: - id: shots if: exists store/renders @@ -24,12 +27,21 @@ steps: - id: wait if: apple run: apple testflight wait --build ${BUILD} --version ${VERSION} + - id: mac-build + if: apple && ${MAC_TAURI} + run: apple build --platform macos --tauri ${MAC_TAURI} --version ${VERSION} --build-number ${BUILD} + - id: mac-upload + if: apple && ${MAC_TAURI} + run: apple upload --platform macos --no-wait --allow-existing - id: play-build if: play run: play build --version-name ${VERSION} - id: play-upload if: play - run: play upload --track internal + run: play upload --track ${PLAY_TRACK} + - id: promote + if: play && ${PLAY_TRACK} != production + owner: "Play Console: promote the ${PLAY_TRACK} release to production (storebound play release --version-code --production completed once you agree)" - id: whats-new if: exists store/whats-new run: whats-new apply @@ -43,4 +55,4 @@ steps: - id: plan run: release plan ${VERSION} - id: submit - owner: "App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the internal release and Send changes for review" + owner: "App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the ${PLAY_TRACK} release and Send changes for review" diff --git a/tests/adhoc.test.ts b/tests/adhoc.test.ts index 5e6d606..2337396 100644 --- a/tests/adhoc.test.ts +++ b/tests/adhoc.test.ts @@ -247,7 +247,7 @@ describe('apple adhoc runs (mocked App Store Connect)', () => { expect(out).toContain('Old pad (aaaa…aaab) not registered'); expect(err).toBe('1 of 2 device(s) not registered'); expect(leaks(out + err)).toEqual([]); - }); + }, 20_000); // signs an ES256 token and runs two POSTs: over 5 s on a busy CI runner test('register --udid with a bad UDID fails before any call, masked', async () => { ascKey(); diff --git a/tests/analytics-collector.test.ts b/tests/analytics-collector.test.ts new file mode 100644 index 0000000..4d26a56 --- /dev/null +++ b/tests/analytics-collector.test.ts @@ -0,0 +1,264 @@ +// What an external collector needs from `analytics snapshot` and `reports`: a store-only config (apple.appId without a +// bundle id, no play.reportsBucket), --json on stdout, App Usage and Play install stock numbers per day, and per-day +// sales JSON for a day range of both stores. Fixtures are made-up report files; nothing reaches the network. +import { afterEach, describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { deflateRawSync } from 'node:zlib'; +import { run as analytics } from '../cli/analytics'; +import { appleDaily, appleTotals, mergeAppleUsage, parseReport, playDaily, playTotals } from '../cli/lib/analytics'; +import { type ShipConfig, validateConfig } from '../cli/lib/config'; +import { playReportsBucket, profileEnv } from '../cli/lib/credentials'; +import { dateRange, parsePlayCsv, parseTsv, playSalesTotals, rangeReport, reportRange, summarizeAppleSales, summarizePlaySalesByDay, totalsByCurrency } from '../cli/lib/reports'; +import { run as reports } from '../cli/reports'; +import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore } from './helpers/store-mock'; + +const BUCKET = 'pubsite_prod_rev_01234567890123456789'; +afterEach(() => { restore(); delete process.env.PLAY_REPORTS_BUCKET; delete process.env.STOREBOUND_CREDENTIALS_DIR; delete process.env.STOREBOUND_PROFILE; }); + +const iso = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); +type Req = { path: string }; + +function zip(files: Record): Uint8Array { + const parts: Uint8Array[] = [], central: Uint8Array[] = []; + let off = 0; + for (const [name, text] of Object.entries(files)) { + const raw = new TextEncoder().encode(text), data = deflateRawSync(raw), n = new TextEncoder().encode(name); + const h = new DataView(new ArrayBuffer(30)); + h.setUint32(0, 0x04034b50, true); h.setUint16(8, 8, true); h.setUint32(18, data.length, true); h.setUint32(22, raw.length, true); h.setUint16(26, n.length, true); + const c = new DataView(new ArrayBuffer(46)); + c.setUint32(0, 0x02014b50, true); c.setUint16(10, 8, true); c.setUint32(20, data.length, true); c.setUint32(24, raw.length, true); c.setUint16(28, n.length, true); c.setUint32(42, off, true); + parts.push(new Uint8Array(h.buffer), n, data); + central.push(new Uint8Array(c.buffer), n); + off += 30 + n.length + data.length; + } + const cdSize = central.reduce((s, x) => s + x.length, 0); + const e = new DataView(new ArrayBuffer(22)); + e.setUint32(0, 0x06054b50, true); e.setUint16(8, Object.keys(files).length, true); e.setUint16(10, Object.keys(files).length, true); e.setUint32(12, cdSize, true); e.setUint32(16, off, true); + const all = [...parts, ...central, new Uint8Array(e.buffer)]; + const out = new Uint8Array(all.reduce((s, x) => s + x.length, 0)); + let p = 0; + for (const x of all) { out.set(x, p); p += x.length; } + return out; +} + +// ---- pure parts ---- + +describe('App Usage and Play install stock numbers', () => { + const SESSIONS = 'Date\tApp Name\tApp Apple Identifier\tApp Version\tDevice\tPlatform Version\tSource Type\tSource Info\tTerritory\tSessions\tTotal Session Duration\tUnique Devices\n' + + '2026-10-01\tExample\t1\t1.0\tiPhone\tiOS 26\t\t\tUS\t100\t3000\t40\n2026-10-01\tExample\t1\t1.0\tiPad\tiPadOS 26\t\t\tTR\t20\t600\t8\n2026-10-02\tExample\t1\t1.0\tiPhone\tiOS 26\t\t\tUS\t50\t1000\t30\n'; + const CRASHES = 'Date\tApp Name\tApp Apple Identifier\tApp Version\tDevice\tPlatform Version\tCrashes\tUnique Devices\n2026-10-01\tExample\t1\t1.0\tiPhone\tiOS 26\t4\t3\n2026-10-03\tExample\t1\t1.0\tiPhone\tiOS 26\t1\t1\n'; + test('sessions, active devices, session time and crashes per day, merged into the engagement days', () => { + const base = appleDaily(parseReport('Date\tEvent\tCounts\tUnique Counts\n2026-10-01\tImpression\t10\t8\n'), []); + const d = mergeAppleUsage(base, parseReport(SESSIONS), parseReport(CRASHES)); + expect(d.map(x => x.date)).toEqual(['2026-10-01', '2026-10-02', '2026-10-03']); + expect(d[0]).toMatchObject({ impressions: 10, sessions: 120, activeDevices: 48, sessionSeconds: 3600, crashes: 4 }); + expect(d[1]).toMatchObject({ impressions: 0, sessions: 50, activeDevices: 30 }); + expect(d[1]!.crashes).toBeUndefined(); + expect(d[2]).toMatchObject({ crashes: 1 }); + expect(appleTotals(d)).toMatchObject({ sessions: 170, crashes: 5, activeDevicesAvg: 39 }); + expect(appleTotals(base).sessions).toBeUndefined(); + }); + test('Play: stock columns from the overview file, else summed over the country rows; totals take the newest day', () => { + const country = parseReport('Date,Package Name,Country,Daily Device Installs,Daily User Installs,Total User Installs,Active Device Installs\n' + + '2026-10-01,p,US,5,4,100,60\n2026-10-01,p,TR,2,2,50,30\n2026-10-02,p,US,1,1,101,61\n'); + expect(playDaily([], country)).toEqual([ + { date: '2026-10-01', visitors: 0, acquisitions: 0, installs: 6, activeDeviceInstalls: 90, totalUserInstalls: 150 }, + { date: '2026-10-02', visitors: 0, acquisitions: 0, installs: 1, activeDeviceInstalls: 61, totalUserInstalls: 101 }]); + const overview = parseReport('Date,Package Name,Total User Installs,Active Device Installs\n2026-10-01,p,160,95\n2026-10-02,p,162,96\n'); + const d = playDaily([], country, overview); + expect(d[0]).toMatchObject({ installs: 6, activeDeviceInstalls: 95, totalUserInstalls: 160 }); + expect(playTotals(d)).toMatchObject({ installs: 7, activeDeviceInstalls: 96, totalUserInstalls: 162 }); + expect(playDaily([], parseReport('Date,Daily User Installs\n2026-10-01,3\n'))[0]!.activeDeviceInstalls).toBeUndefined(); + }); +}); + +describe('store-only config and bucket fallback', () => { + const virtual = (apple: Record): unknown => ({ ...BASE, apple, play: { packageName: 'com.example.notes' } }); + test('apple.appId alone is enough; a missing id is still an error', () => { + expect(validateConfig(virtual({ bundleId: null, appId: '6400000101' }))).toEqual([]); + expect(validateConfig(virtual({ appId: '6400000101' }))).toEqual([]); + expect(validateConfig(virtual({ bundleId: null }))[0]).toContain('apple.bundleId'); + expect(validateConfig(virtual({ bundleId: 'nope' }))[0]).toContain('apple.bundleId'); + expect(validateConfig(virtual({ bundleId: null, appId: 'abc' }))[0]).toContain('apple.bundleId'); + }); + test('reports bucket: config, then env PLAY_REPORTS_BUCKET, then the profile field reportsBucket', () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-prof-')); + writeFileSync(join(dir, 'acme.json'), JSON.stringify({ reportsBucket: `gs://${BUCKET}/` })); + process.env.STOREBOUND_CREDENTIALS_DIR = dir; + process.env.STOREBOUND_PROFILE = 'acme'; + const root = mkdtempSync(join(tmpdir(), 'sb-root-')); + expect(profileEnv('acme').PLAY_REPORTS_BUCKET).toBe(`gs://${BUCKET}/`); + expect(playReportsBucket(root)).toBe(`gs://${BUCKET}/`); + process.env.PLAY_REPORTS_BUCKET = 'pubsite_prod_rev_999'; + expect(playReportsBucket(root)).toBe('pubsite_prod_rev_999'); + expect(playReportsBucket(root, BUCKET)).toBe(BUCKET); + process.env.PLAY_REPORTS_BUCKET = 'my-bucket'; + expect(() => playReportsBucket(root)).toThrow('PLAY_REPORTS_BUCKET'); + delete process.env.PLAY_REPORTS_BUCKET; + process.env.STOREBOUND_PROFILE = ''; + expect(playReportsBucket(root)).toBeNull(); + }); +}); + +describe('day ranges and daily sales', () => { + test('reportRange: --days ends yesterday, --from/--to inclusive, checks', () => { + const now = new Date('2026-10-26T10:00:00Z'); + expect(reportRange({}, now)).toBeNull(); + expect(reportRange({ days: '3' }, now)).toEqual({ from: '2026-10-23', to: '2026-10-25', dates: ['2026-10-23', '2026-10-24', '2026-10-25'] }); + expect(reportRange({ from: '2026-10-24' }, now)!.dates).toEqual(['2026-10-24', '2026-10-25']); + expect(reportRange({ from: '2026-10-24', to: '2026-10-24' }, now)!.dates).toEqual(['2026-10-24']); + expect(() => reportRange({ days: '0' }, now)).toThrow('--days'); + expect(() => reportRange({ days: '2', from: '2026-10-01' }, now)).toThrow('not both'); + expect(() => reportRange({ from: '2026-10-03', to: '2026-10-01' }, now)).toThrow('after'); + expect(() => reportRange({ from: '10/03/2026' }, now)).toThrow('YYYY-MM-DD'); + expect(() => reportRange({ to: '2026-10-03' }, now)).toThrow('--from'); + expect(dateRange('2026-02-27', '2026-03-02')).toHaveLength(4); + }); + test('rangeReport: totals per currency over the days, the dates without a report listed', () => { + const tsv = (units: number) => ['Provider\tSKU\tTitle\tUnits\tDeveloper Proceeds\tCurrency of Proceeds', `APPLE\tcom.example.pro\tPro\t${units}\t2.5\tUSD`, 'APPLE\tcom.example.pro\tPro\t1\t3\tEUR', ''].join('\n'); + const day = (date: string, units: number) => { const summary = summarizeAppleSales(parseTsv(tsv(units))); return { date, rows: 2, summary, totals: totalsByCurrency(summary) }; }; + const r = rangeReport([day('2026-10-02', 4), day('2026-10-01', 2)], ['2026-10-01', '2026-10-02', '2026-10-03']); + expect(r.days.map(d => d.date)).toEqual(['2026-10-01', '2026-10-02']); + expect(r.totals).toEqual([{ currency: 'EUR', units: 2, amount: 6 }, { currency: 'USD', units: 6, amount: 15 }]); + expect(r.missing).toEqual(['2026-10-03']); + }); + test('Play sales per order-charged date and currency: charged, refunds (negative), net', () => { + const csv = 'Order Number,Order Charged Date,Financial Status,SKU ID,Currency of Sale,Item Price,Taxes Collected,Charged Amount\n' + + 'A1,2026-10-01,Charged,pro,USD,4.99,0.00,4.99\nA2,2026-10-01,Charged,pro,USD,4.00,0.50,4.50\nA3,2026-10-01,Charged,pro,EUR,5.00,1.00,6.00\n' + + 'A4,2026-10-02,Refund,pro,USD,4.99,0.00,4.99\nA5,2026-10-02,Refund,pro,USD,-1.00,0.00,-1.00\nA6,2026-09-30,Charged,pro,USD,9,0,9\nA7,2026-10-02,Other,pro,USD,9,0,9\n'; + const d = summarizePlaySalesByDay(parsePlayCsv(csv), '2026-10-01', '2026-10-03'); + expect(d).toEqual([ + { date: '2026-10-01', currency: 'EUR', orders: 1, refunds: 0, itemPrice: 5, taxes: 1, charged: 6, refunded: 0, net: 6 }, + { date: '2026-10-01', currency: 'USD', orders: 2, refunds: 0, itemPrice: 8.99, taxes: 0.5, charged: 9.49, refunded: 0, net: 9.49 }, + { date: '2026-10-02', currency: 'USD', orders: 0, refunds: 2, itemPrice: 0, taxes: 0, charged: 0, refunded: -5.99, net: -5.99 }]); + expect(playSalesTotals(d)).toEqual([{ currency: 'EUR', orders: 1, refunds: 0, charged: 6, refunded: 0, net: 6 }, { currency: 'USD', orders: 2, refunds: 2, charged: 9.49, refunded: -5.99, net: 3.5 }]); + }); +}); + +// ---- the commands, as the collector calls them ---- + +const md5 = (s: string) => createHash('md5').update(s).digest('hex'); + +describe('analytics snapshot on a store-only config', () => { + const d1 = iso(-1), d2 = iso(-2); + const ENG = `Date\tEvent\tCounts\tUnique Counts\n${d1}\tImpression\t100\t80\n${d1}\tPage view\t30\t25\n${d2}\tImpression\t60\t50\n`; + const DL = `Date,Download Type,Counts\n${d1},First-time Download,10\n${d2},Redownload,2\n`; + const SES = `Date\tSessions\tTotal Session Duration\tUnique Devices\n${d1}\t200\t6000\t90\n${d2}\t150\t4000\t70\n`; + const CRA = `Date\tCrashes\tUnique Devices\n${d1}\t3\t2\n`; + const files: Record = { eng: ENG, dl: DL, ses: SES, cra: CRA }; + const reportsList = [ + { id: 'r-eng', attributes: { category: 'APP_STORE_ENGAGEMENT', name: 'App Store Discovery and Engagement Standard' } }, + { id: 'r-dl', attributes: { category: 'COMMERCE', name: 'App Store Downloads Standard' } }, + { id: 'r-ses', attributes: { category: 'APP_USAGE', name: 'App Sessions Standard' } }, + { id: 'r-cra', attributes: { category: 'APP_USAGE', name: 'App Crashes Standard' } }, + ]; + const appleRoutes = (list = reportsList) => [ + { match: /\/v1\/apps\/6400000101\/analyticsReportRequests\?/, answer: { data: [{ id: 'req1', attributes: {} }] } }, + { match: /\/v1\/analyticsReportRequests\/req1\/reports\?/, answer: { data: list } }, + { match: /\/v1\/analyticsReports\/r-(\w+)\/instances\?/, answer: (c: Req) => { const k = /r-(\w+)\//.exec(decodeURIComponent(c.path))![1]!; return { data: [{ id: `i-${k}`, attributes: { processingDate: iso(0) } }] }; } }, + { match: /\/v1\/analyticsReportInstances\/i-(\w+)\/segments\?/, answer: (c: Req) => { const k = /i-(\w+)\//.exec(decodeURIComponent(c.path))![1]!; return { data: [{ id: `s-${k}`, attributes: { url: `https://segments.example.com/seg/${k}`, checksum: md5(files[k]!) } }] }; } }, + { match: /^\/seg\/(\w+)$/, answer: (c: Req) => new Response(files[c.path.slice(5)]!) }, + ]; + const virtualConfig = (): ShipConfig => ({ ...BASE, apple: { bundleId: null as unknown as string, appId: '6400000101' }, play: { packageName: 'com.example.notes' } }); + + test('apple: appId only, App Usage per day, --json on stdout, --no-write leaves no file', async () => { + ascEnv(); + const p = project(virtualConfig()); + const calls = mockFetch(appleRoutes()); + const r = await capture(() => analytics(p.ctx(['snapshot', '--store', 'apple', '--days', '3', '--json', '--no-write']))); + const snap = jsonOut(r.out) as { apple: { daily: Record[]; totals: Record } }; + expect(snap.apple.daily.find(x => x.date === d1)).toMatchObject({ impressions: 100, pageViews: 30, downloads: 10, sessions: 200, activeDevices: 90, sessionSeconds: 6000, crashes: 3 }); + expect(snap.apple.daily.find(x => x.date === d2)).toMatchObject({ sessions: 150, activeDevices: 70 }); + expect(snap.apple.totals).toMatchObject({ sessions: 350, crashes: 3, activeDevicesAvg: 80 }); + expect(calls.some(c => /filter\[bundleId\]/.test(decodeURIComponent(c.path)))).toBe(false); + expect(existsSync(join(p.root, '.storebound/analytics'))).toBe(false); + }); + test('apple: without the usage reports the rest still comes, with a note', async () => { + ascEnv(); + const p = project(virtualConfig()); + mockFetch(appleRoutes(reportsList.slice(0, 2))); + const r = await capture(() => analytics(p.ctx(['snapshot', '--store', 'apple', '--days', '3', '--json']))); + const snap = jsonOut(r.out) as { apple: { daily: Record[]; notes: string[] } }; + expect(snap.apple.daily.find(x => x.date === d1)!.impressions).toBe(100); + expect(snap.apple.daily[0]!.sessions).toBeUndefined(); + expect(snap.apple.notes[0]).toContain('App Usage'); + expect(existsSync(join(p.root, '.storebound/analytics', `${iso(0)}.json`))).toBe(true); + }); + test('play: the bucket from env, install stock numbers, daily user installs', async () => { + playEnv(); + process.env.PLAY_REPORTS_BUCKET = BUCKET; + const p = project(virtualConfig()); + const month = (d: string) => d.slice(0, 7).replace('-', ''); + const inMonth = (m: string) => [d2, d1].filter(d => month(d) === m); + const mOf = (c: Req) => /_(\d{6})_/.exec(decodeURIComponent(c.path))![1]!; + mockFetch([ + { match: /store_performance_com\.example\.notes_(\d{6})_country\.csv\?alt=media$/, answer: (c: Req) => new Response(`Date,Package name,Country,Store listing acquisitions,Store listing visitors\n${inMonth(mOf(c)).map(d => `${d},com.example.notes,US,5,50`).join('\n')}\n`) }, + { match: /installs_com\.example\.notes_(\d{6})_country\.csv\?alt=media$/, answer: (c: Req) => new Response(`Date,Package Name,Country,Daily User Installs,Total User Installs,Active Device Installs\n${inMonth(mOf(c)).map(d => `${d},com.example.notes,US,4,100,60`).join('\n')}\n`) }, + { match: /installs_com\.example\.notes_(\d{6})_overview\.csv\?alt=media$/, answer: (c: Req) => new Response(`Date,Package Name,Total User Installs,Active Device Installs\n${inMonth(mOf(c)).map(d => `${d},com.example.notes,${d === d1 ? 120 : 110},${d === d1 ? 70 : 65}`).join('\n')}\n`) }, + ]); + const r = await capture(() => analytics(p.ctx(['snapshot', '--store', 'play', '--days', '3', '--json', '--dry']))); + const snap = jsonOut(r.out) as { play: { daily: Record[]; totals: Record } }; + expect(snap.play.daily.find(x => x.date === d1)).toMatchObject({ visitors: 50, acquisitions: 5, installs: 4, activeDeviceInstalls: 70, totalUserInstalls: 120 }); + expect(snap.play.totals).toMatchObject({ installs: 8, activeDeviceInstalls: 70, totalUserInstalls: 120 }); + }); + test('play without any bucket: the command fails (nothing to write)', async () => { + playEnv(); + const p = project(virtualConfig()); + mockFetch([]); + await expect(capture(() => analytics(p.ctx(['snapshot', '--store', 'play', '--days', '3', '--json'])))).rejects.toThrow('nothing written'); + }); +}); + +describe('reports over a day range', () => { + test('apple-sales --days: one JSON with the days, totals per currency, the missing day listed', async () => { + ascEnv(); + process.env.ASC_VENDOR_NUMBER = '12345678'; + const p = project(); + const yesterday = iso(-1), before = iso(-2); + const tsv = (units: number, proceeds: string, cur: string) => ['Provider\tSKU\tTitle\tUnits\tDeveloper Proceeds\tCurrency of Proceeds', `APPLE\tcom.example.notes.pro\tPro\t${units}\t${proceeds}\t${cur}`, ''].join('\n'); + const calls = mockFetch([{ match: /^\/v1\/salesReports\?/, answer: (c: Req) => { + const date = /reportDate\]=(\d{4}-\d{2}-\d{2})/.exec(decodeURIComponent(c.path))![1]!; + return date === yesterday ? new Response('{"errors":[{"status":"404","code":"NOT_FOUND"}]}', { status: 404 }) : new Response(Bun.gzipSync(new TextEncoder().encode(tsv(2, '3.49', date === before ? 'USD' : 'EUR')))); + } }]); + const r = await capture(() => reports(p.ctx(['apple-sales', '--days', '3', '--json']))); + const j = jsonOut(r.out) as { from: string; to: string; days: { date: string; totals: unknown[] }[]; totals: unknown[]; missing: string[]; rows: number }; + expect(calls).toHaveLength(3); + expect(j.to).toBe(yesterday); + expect(j.days.map(d => d.date)).toEqual([iso(-3), before]); + expect(j.missing).toEqual([yesterday]); + expect(j.totals).toEqual([{ currency: 'EUR', units: 2, amount: 6.98 }, { currency: 'USD', units: 2, amount: 6.98 }]); + expect(j.rows).toBe(2); + expect(existsSync(join(p.root, `.storebound/reports/apple-sales-daily-${before}.tsv`))).toBe(true); + expect(readFileSync(join(p.root, `.storebound/reports/apple-sales-daily-${iso(-3)}_${yesterday}.summary.csv`), 'utf8')).toContain('date,key,title,currency,units,amount,rows'); + }); + test('apple-sales with a --type other than SALES, or --days on finance, is refused', async () => { + ascEnv(); + const p = project(); + await expect(capture(() => reports(p.ctx(['apple-sales', '--days', '3', '--type', 'SUBSCRIPTION', '--vendor', '12345678'])))).rejects.toThrow('SALES'); + await expect(capture(() => reports(p.ctx(['apple-finance', '--days', '3', '--month', '2026-09', '--vendor', '12345678'])))).rejects.toThrow('monthly'); + }); + test('play-sales --days: the daily revenue per currency from the monthly sales zips; play-earnings --days is refused', async () => { + playEnv(); + process.env.PLAY_REPORTS_BUCKET = BUCKET; + const p = project({ ...BASE, play: { packageName: 'com.example.notes' } }); + const d1 = iso(-1), d2 = iso(-2); + const months = new Set([d1, d2].map(d => d.slice(0, 7).replace('-', ''))); + const csv = `Order Number,Order Charged Date,Financial Status,SKU ID,Currency of Sale,Item Price,Taxes Collected,Charged Amount\nA1,${d1},Charged,pro,USD,4.99,0,4.99\nA2,${d2},Charged,pro,USD,4.99,0,4.99\nA3,${d2},Refund,pro,USD,4.99,0,4.99\n`; + mockFetch([ + { match: /\/o\?prefix=sales\/salesreport_(\d{6})$/, answer: (c: Req) => { const m = /salesreport_(\d{6})/.exec(decodeURIComponent(c.path))![1]!; return { items: months.has(m) ? [{ name: `sales/salesreport_${m}.zip` }] : [] }; } }, + { match: /\/o\/sales\/salesreport_\d{6}\.zip\?alt=media$/, answer: () => new Response(zip({ 'salesreport.csv': csv }) as Uint8Array) }, + ]); + const r = await capture(() => reports(p.ctx(['play-sales', '--days', '2', '--json']))); + const j = jsonOut(r.out) as { days: { date: string; net: number }[]; totals: unknown[]; emptyDays: string[] }; + // one zip per month the window touches: a window across a month end reads the same fixture twice, so compare per day + const once = months.size === 1; + expect(j.days).toMatchObject([{ date: d2, currency: 'USD', orders: once ? 1 : 2, refunds: once ? 1 : 2 }, { date: d1, orders: once ? 1 : 2 }]); + if (once) expect(j.totals).toEqual([{ currency: 'USD', orders: 2, refunds: 1, charged: 9.98, refunded: -4.99, net: 4.99 }]); + expect(j.emptyDays).toEqual([]); + expect(existsSync(join(p.root, `.storebound/reports/play-sales-daily-${d2}_${d1}.summary.csv`))).toBe(true); + await expect(capture(() => reports(p.ctx(['play-earnings', '--days', '2'])))).rejects.toThrow('monthly'); + }); +}); diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts index 6b97c4b..24fa5ed 100644 --- a/tests/analytics.test.ts +++ b/tests/analytics.test.ts @@ -51,7 +51,7 @@ describe('apple analytics reports', () => { describe('play statistics exports', () => { test('file names, months, bucket', () => { expect(monthsBetween('2026-11-20', '2027-02-01')).toEqual(['202611', '202612', '202701', '202702']); - expect(playObjects('com.example.game', '202610')).toEqual({ storePerformance: 'stats/store_performance/store_performance_com.example.game_202610_country.csv', installs: 'stats/installs/installs_com.example.game_202610_country.csv' }); + expect(playObjects('com.example.game', '202610')).toEqual({ storePerformance: 'stats/store_performance/store_performance_com.example.game_202610_country.csv', installs: 'stats/installs/installs_com.example.game_202610_country.csv', installsOverview: 'stats/installs/installs_com.example.game_202610_overview.csv' }); expect(bucketName('gs://pubsite_prod_rev_0000/stats')).toBe('pubsite_prod_rev_0000'); }); test('daily sums across countries', () => { diff --git a/tests/apple-release-gaps.test.ts b/tests/apple-release-gaps.test.ts new file mode 100644 index 0000000..14fd382 --- /dev/null +++ b/tests/apple-release-gaps.test.ts @@ -0,0 +1,405 @@ +// Three gaps found in real use, against the stateful App Store Connect double (tests/helpers/fake-stores.ts): +// 1. release type: updates sat in Pending Developer Release because versions were created / submitted as MANUAL +// (apple.releaseType, apple submit, apple release --after-approval | --manual) +// 2. device families: an iPhone-only build of an app whose live version runs on iPad is refused AFTER the archive (ITMS-90101) +// 3. TestFlight: inviting a tester of a group without an installable build is 409 NO_INSTALLABLE_BUILDS +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; +import { parseArgs } from '../cli/lib/args'; +import { runShipStep } from '../cli/lib/ship-step'; +import { run as appleSubmit } from '../cli/apple/submit'; +import { run as appleRelease } from '../cli/apple/release'; +import { run as appleTestflight } from '../cli/apple/testflight'; +import { run as appleBuild } from '../cli/apple/build'; +import { validateConfig } from '../cli/lib/config'; +import { planVersion } from '../cli/lib/listing-plan'; +import { releaseTypeChanges, validateReleaseType, wantedRelease } from '../cli/lib/release-type'; +import { familyVerdicts, liveFamilies, pbxFamilies, projectFamilies } from '../cli/lib/device-families'; +import { liveDeviceFamilies } from '../cli/apple/device-families'; +import { appleRules } from '../cli/lib/validate-rules'; +import { installableBuild, noInstallableBuildHint, noInstallableBuilds409 } from '../cli/lib/testflight'; +import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores'; + +const APP = '6400000101'; + +/** Runs a ship step in-process with stdout / stderr captured. */ +async function ship(key: string, argv: string[], body: (a: ReturnType) => Promise): Promise<{ code: number; out: string; err: string }> { + const out: string[] = [], err: string[] = []; + const o = process.stdout.write, e = process.stderr.write, cl = console.log, ce = console.error, cw = console.warn, ci = console.info; + process.stdout.write = ((s: string | Uint8Array) => { out.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stdout.write; + process.stderr.write = ((s: string | Uint8Array) => { err.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stderr.write; + console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ') + '\n'); }; + console.info = console.log; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ') + '\n'); }; + console.warn = console.error; + try { + const code = await runShipStep(key, argv, parseArgs, async a => { + const keep = console.log; + if (a.has('json')) console.log = (...x: unknown[]) => console.error(...x); + try { await body(a); } finally { console.log = keep; } + }); + return { code, out: out.join(''), err: err.join('') }; + } finally { process.stdout.write = o; process.stderr.write = e; console.log = cl; console.error = ce; console.warn = cw; console.info = ci; } +} + +// ------------------------------------------------------------------------------------------------ 1. release type + +describe('release type (pure)', () => { + test('config: afterApproval | manual | scheduled (+ date); scheduled needs the date, the date needs scheduled', () => { + expect(validateReleaseType({ releaseType: 'afterApproval' })).toEqual([]); + expect(validateReleaseType({ releaseType: 'scheduled', earliestReleaseDate: '2027-03-01T09:00:00Z' })).toEqual([]); + expect(validateReleaseType({ releaseType: 'soon' }).join()).toContain('afterApproval | manual | scheduled'); + expect(validateReleaseType({ releaseType: 'scheduled' }).join()).toContain('earliestReleaseDate is required'); + expect(validateReleaseType({ releaseType: 'manual', earliestReleaseDate: '2027-03-01T09:00:00Z' }).join()).toContain('only with apple.releaseType "scheduled"'); + expect(validateReleaseType({ releaseType: 'scheduled', earliestReleaseDate: 'tomorrow' }).join()).toContain('ISO 8601'); + expect(validateConfig({ ...BASE, apple: { ...BASE.apple!, releaseType: 'later' as never } }).join()).toContain('apple.releaseType'); + expect(wantedRelease({})).toBeNull(); + expect(wantedRelease({ releaseType: 'afterApproval' })).toEqual({ releaseType: 'AFTER_APPROVAL' }); + }); + test('changes are old -> new; the same instant is not a change', () => { + expect(releaseTypeChanges({ releaseType: 'MANUAL' }, { releaseType: 'AFTER_APPROVAL' })).toEqual([{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }]); + expect(releaseTypeChanges({ releaseType: 'AFTER_APPROVAL' }, { releaseType: 'AFTER_APPROVAL' })).toEqual([]); + expect(releaseTypeChanges({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00+00:00' }, { releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' })).toEqual([]); + expect(releaseTypeChanges({ releaseType: 'MANUAL' }, { releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }).map(c => c.field)).toEqual(['releaseType', 'earliestReleaseDate']); + }); + test('apple listing: a new version takes the configured type (MANUAL when unset); an existing one keeps its own unless asked', () => { + const rows = [{ id: 'v1', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' } }]; + expect(planVersion(rows, '1.1.0', { copyright: 'c' }).attributes.releaseType).toBe('MANUAL'); + expect(planVersion(rows, '1.1.0', { copyright: 'c', releaseType: 'AFTER_APPROVAL' }).attributes.releaseType).toBe('AFTER_APPROVAL'); + const sched = planVersion(rows, '1.1.0', { copyright: 'c', releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }); + expect(sched.attributes).toMatchObject({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }); + const editable = [...rows, { id: 'v2', attributes: { versionString: '1.1.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS', copyright: 'c', releaseType: 'AFTER_APPROVAL' } }]; + expect(planVersion(editable, '1.1.0', { copyright: 'c' }).action).toBe('keep'); + expect(planVersion(editable, '1.1.0', { copyright: 'c', releaseType: 'MANUAL' }).changes).toEqual([{ field: 'releaseType', from: 'AFTER_APPROVAL', to: 'MANUAL' }]); + }); +}); + +describe('release type (fake App Store Connect)', () => { + setDefaultTimeout(30_000); + let s: Stores; + beforeAll(() => { ascEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); }); + afterEach(() => { uninstallStores(); }); + const version = (id: string, v: string, state: string, releaseType = 'MANUAL'): void => { s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString: v, appVersionState: state, platform: 'IOS', releaseType }, rels: { app: APP } }); }; + const build = (): void => { s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); }; + const typeOf = (id: string): unknown => s.asc.all('appStoreVersions').find(v => v.id === id)!.attributes.releaseType; + const cfg = (releaseType: 'afterApproval' | 'manual') => ({ ...BASE, apple: { ...BASE.apple!, releaseType } }); + + test('apple submit with apple.releaseType: the dry run shows old -> new, the real run sets it BEFORE the submit, the re-run is exit 7', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); + build(); + const p = project(cfg('afterApproval')); + const dry = await ship('apple submit', ['--json', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(dry.code).toBe(0); + expect(JSON.parse(dry.out).releaseType).toEqual({ changes: [{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }] }); + expect(typeOf('ver-1')).toBe('MANUAL'); + expect(writes(s.calls)).toEqual([]); + const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(r.err).not.toContain('error:'); + expect(r.code).toBe(0); + expect(r.err).toContain('release type of 1.1.0: releaseType MANUAL -> AFTER_APPROVAL'); + expect(typeOf('ver-1')).toBe('AFTER_APPROVAL'); + const w = writes(s.calls).map(c => `${c.method} ${c.path}`); + expect(w.indexOf('PATCH /v1/appStoreVersions/ver-1')).toBeGreaterThanOrEqual(0); + expect(w.indexOf('PATCH /v1/appStoreVersions/ver-1')).toBeLessThan(w.findIndex(x => /^PATCH \/v1\/reviewSubmissions\//.test(x))); + const again = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(again.code).toBe(7); + }); + + test('apple submit without apple.releaseType leaves the version\'s release type alone', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); + build(); + const p = project(BASE); + const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(r.code).toBe(0); + expect(typeOf('ver-1')).toBe('MANUAL'); + expect(writes(s.calls).some(c => c.path === '/v1/appStoreVersions/ver-1')).toBe(false); + }); + + test('apple release --after-approval --yes on an update: set, then exit 7 when it is that type; --manual puts it back', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'WAITING_FOR_REVIEW'); + const p = project(BASE); + const run = (argv: string[]) => ship('apple release', ['--json', '--yes', ...argv], a => appleRelease({ loaded: p.loaded, args: a })); + const r = await run(['--after-approval', '--version', '1.1.0']); + expect(r.err).not.toContain('error:'); + expect(r.code).toBe(0); + expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'release', version: '1.1.0', releaseType: 'AFTER_APPROVAL', from: 'MANUAL', changed: true, releaseKind: 'update' }); + expect(writes(s.calls).map(c => `${c.method} ${c.path}`)).toEqual(['PATCH /v1/appStoreVersions/ver-1']); + expect(typeOf('ver-1')).toBe('AFTER_APPROVAL'); + const n = s.calls.length; + const again = await run(['--after-approval']); + expect(again.code).toBe(7); + expect(JSON.parse(again.out)).toMatchObject({ ok: true, alreadyThere: true }); + expect(writes(s.calls.slice(n))).toEqual([]); + const back = await run(['--manual', '--version', '1.1.0']); + expect(back.code).toBe(0); + expect(typeOf('ver-1')).toBe('MANUAL'); + }); + + test('apple release --after-approval --dry changes nothing; --scheduled sets the date; two type flags are a usage error', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); + const p = project(BASE); + const dry = await ship('apple release', ['--json', '--after-approval', '--dry'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(dry.code).toBe(0); + expect(JSON.parse(dry.out)).toMatchObject({ dryRun: true, changes: [{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }] }); + expect(writes(s.calls)).toEqual([]); + const sch = await ship('apple release', ['--json', '--yes', '--scheduled', '2027-03-01T09:00:00Z'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(sch.code).toBe(0); + expect(s.asc.all('appStoreVersions').find(v => v.id === 'ver-1')!.attributes).toMatchObject({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }); + const both = await ship('apple release', ['--json', '--yes', '--manual', '--after-approval'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(both.code).not.toBe(0); + expect(both.err).toContain('alternatives'); + }); + + test('an approved version (Pending Developer Release) is refused with the way out; a released one too', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PENDING_DEVELOPER_RELEASE'); + const p = project(BASE); + const r = await ship('apple release', ['--json', '--yes', '--after-approval', '--version', '1.1.0'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(r.code).not.toBe(0); + expect(r.err).toContain('apple release --manual-release --version 1.1.0'); + expect(writes(s.calls)).toEqual([]); + // already MANUAL and asked for MANUAL: nothing to do even in that state + const same = await ship('apple release', ['--json', '--yes', '--manual', '--version', '1.1.0'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(same.code).toBe(7); + }); + + test('--after-approval on the first version of an app is the owner\'s step (exit 3 OWNER_STEP) unless --first-release', async () => { + version('ver-1', '1.0.0', 'PREPARE_FOR_SUBMISSION'); + const p = project(BASE); + const r = await ship('apple release', ['--json', '--yes', '--after-approval'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(r.code).toBe(3); + expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); + expect(writes(s.calls)).toEqual([]); + const manual = await ship('apple release', ['--json', '--yes', '--manual'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(manual.code).toBe(7); + const ok = await ship('apple release', ['--json', '--yes', '--after-approval', '--first-release'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(ok.code).toBe(0); + expect(typeOf('ver-1')).toBe('AFTER_APPROVAL'); + }); +}); + +// ------------------------------------------------------------------------------------------------ 2. device families + +const PBX = (family: string, bundle = 'com.example.notes'): string => `// !$*UTF8*$! +{ + objects = { +/* Begin XCBuildConfiguration section */ + AA01 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ${bundle}; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA02 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = ZZ01; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ${bundle}; + TARGETED_DEVICE_FAMILY = ${family}; + }; + name = Release; + }; + AA03 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ${bundle}.Share; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = appex; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + }; +} +`; + +describe('device families (pure)', () => { + test('pbxproj: the Release configuration of the app\'s bundle id, not the extension\'s or Debug', () => { + expect(pbxFamilies(PBX('1'), 'com.example.notes')).toMatchObject({ list: [1], families: { iphone: true, ipad: false }, configurations: ['1'] }); + expect(pbxFamilies(PBX('"1,2"'), 'com.example.notes')).toMatchObject({ list: [1, 2], families: { iphone: true, ipad: true } }); + expect(pbxFamilies('no project here', 'com.example.notes')).toBeNull(); + }); + test('live signals: any iPad signal means iPad; no signal at all is unknown, never iPhone only', () => { + expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'screenshot sets', values: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }])?.families).toEqual({ iphone: true, ipad: true }); + expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: [] }, { name: 'b', values: ['APP_IPHONE_67'] }])?.families).toEqual({ iphone: true, ipad: false }); + expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: [] }])).toBeNull(); + }); + test('verdicts: the exact fix; unknown is a warning; iPhone-only live version is fine', () => { + const live = liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'screenshot sets', values: ['APP_IPAD_PRO_3GEN_129'] }]); + const project = pbxFamilies(PBX('1'), 'com.example.notes', 'ios/App/App.xcodeproj/project.pbxproj'); + const v = familyVerdicts({ live, project, ipad: false }); + expect(v.map(x => x.level)).toEqual(['blocker', 'blocker']); + expect(v[0]!.msg).toContain('ITMS-90101'); + expect(v[0]!.fix).toContain('"1,2"'); + expect(v[1]!.fix).toContain('apple.ipad: true'); + expect(familyVerdicts({ live, project: pbxFamilies(PBX('"1,2"'), 'com.example.notes'), ipad: true }).map(x => x.level)).toEqual(['ok']); + expect(familyVerdicts({ live: null, project, ipad: false })[0]!.level).toBe('warn'); + expect(familyVerdicts({ live: liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: ['APP_IPHONE_67'] }]), project, ipad: false })[0]!.level).toBe('ok'); + }); + test('validate rules turn the verdicts into findings', () => { + const f = appleRules({ deviceFamilies: [{ level: 'blocker', msg: 'x ITMS-90101', fix: 'y' }], version: null, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com', status: 200 } }).filter(x => x.code === 'device-families'); + expect(f).toMatchObject([{ store: 'apple', level: 'blocker', code: 'device-families', fix: 'y' }]); + }); +}); + +describe('device families (fake App Store Connect + project)', () => { + setDefaultTimeout(30_000); + let s: Stores; + beforeAll(() => { ascEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); }); + afterEach(() => { uninstallStores(); }); + /** a live version 1.0.0 with a build whose bundle names the families; screenshotSets: display types of its first locale */ + function live(o: { protocols?: string[]; sets?: string[]; models?: string[] }): void { + s.asc.put({ type: 'appStoreVersions', id: 'ver-live', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } }); + s.asc.put({ type: 'builds', id: 'b-live', attributes: { version: '4', processingState: 'VALID' }, rels: { app: APP, appStoreVersion: 'ver-live' } }); + s.asc.put({ type: 'buildBundles', id: 'bb-live', attributes: { bundleId: 'com.example.notes', bundleType: 'APP', deviceProtocols: o.protocols ?? [] }, rels: { build: 'b-live' } }); + for (const [i, m] of (o.models ?? []).entries()) s.asc.put({ type: 'buildBundleFileSizes', id: `fs-${i}`, attributes: { deviceModel: m }, rels: { buildBundle: 'bb-live' } }); + if (o.sets) { + s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-live', attributes: { locale: 'en-US' }, rels: { appStoreVersion: 'ver-live' } }); + for (const [i, t] of o.sets.entries()) s.asc.put({ type: 'appScreenshotSets', id: `set-${i}`, attributes: { screenshotDisplayType: t }, rels: { appStoreVersionLocalization: 'loc-live' } }); + } + } + const IPHONE_ONLY = { 'ios/App/App.xcodeproj/project.pbxproj': PBX('1') }; + const cfg = { ...BASE, apple: { ...BASE.apple!, ipad: false, signing: { teamId: 'ABCDE12345' } } }; + + test('the live version\'s families are read from the build bundle, its device models and its screenshot sets', async () => { + const c = (await import('../cli/apple/asc')).ascClient; + live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }); + expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.families).toEqual({ iphone: true, ipad: true }); + uninstallStores(); s = installStores(); + live({ protocols: ['iphone', 'ipad'] }); + expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.evidence[0]).toContain('deviceProtocols'); + uninstallStores(); s = installStores(); + live({ models: ['iPad_Pro_12_9_6th', 'iPhone15,2'] }); + expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.families.ipad).toBe(true); + uninstallStores(); s = installStores(); + expect(await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP)).toBeNull(); + }); + + test('apple build stops BEFORE the archive when the project drops iPad that the live version has; --allow-device-gap goes on', async () => { + live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }); + const argv = ['--build-number', '5', '--no-prebuild']; + const p = project(cfg, IPHONE_ONLY); + const r = await capture(() => appleBuild(p.ctx(argv))).catch(e => ({ out: '', err: String((e as Error).message ?? e), exit: 1 })); + expect(r.exit).toBe(1); + expect(r.err).toContain('stopped before the archive'); + expect(r.err).toContain('ITMS-90101'); + expect(r.err).toContain('TARGETED_DEVICE_FAMILY to "1,2"'); + expect(r.err).toContain('apple.ipad: true'); + expect(r.err).toContain('--allow-device-gap'); + // dry: warns, never throws + const dry = await capture(() => appleBuild(p.ctx([...argv, '--dry']))); + expect(dry.err).toContain('ITMS-90101'); + // the escape hatch passes the check (the run then stops later: no Xcode on this machine) + const gap = await capture(() => appleBuild(p.ctx([...argv, '--allow-device-gap']))).catch(e => ({ out: '', err: String((e as Error).message ?? e), exit: 1 })); + expect(gap.err).not.toContain('stopped before the archive'); + }); + + test('apple build passes the check with iPad in the project and apple.ipad true; an iPhone-only live version never blocks', async () => { + live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }); + const ok = project({ ...cfg, apple: { ...cfg.apple, ipad: true } }, { 'ios/App/App.xcodeproj/project.pbxproj': PBX('"1,2"') }); + const r = await capture(() => appleBuild(ok.ctx(['--build-number', '5', '--no-prebuild', '--dry']))); + expect(r.err).not.toContain('ITMS-90101'); + uninstallStores(); s = installStores(); + live({ sets: ['APP_IPHONE_67'] }); + const iph = project(cfg, IPHONE_ONLY); + const r2 = await capture(() => appleBuild(iph.ctx(['--build-number', '5', '--no-prebuild', '--dry']))); + expect(r2.err).not.toContain('ITMS-90101'); + }); + + test('projectFamilies reads the project found under the root (and apple.build.project)', () => { + const p = project(cfg, IPHONE_ONLY); + expect(projectFamilies(p.root, 'com.example.notes')).toMatchObject({ file: 'ios/App/App.xcodeproj/project.pbxproj', list: [1] }); + expect(projectFamilies(p.root, 'com.example.notes', 'ios/App/App.xcodeproj')?.list).toEqual([1]); + expect(projectFamilies(project(cfg).root, 'com.example.notes')).toBeNull(); + }); +}); + +// ------------------------------------------------------------------------------------------------ 3. TestFlight invite + +describe('TestFlight invite needs an installable build', () => { + setDefaultTimeout(30_000); + let s: Stores; + beforeAll(() => { ascEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); }); + afterEach(() => { uninstallStores(); }); + const CSV = { '.storebound/testers.csv': 'email,first_name,groups\ntester1@example.com,Ex,Friends\n' }; + const cfg = { ...BASE, apple: { ...BASE.apple!, testflight: { groups: [{ name: 'Friends' }] } } }; + const group = (): void => { s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } }); }; + const buildIn = (external: string): void => { + s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '7', processingState: 'VALID', expired: false }, rels: { app: APP, betaGroups: ['grp-friends'] } }); + s.asc.put({ type: 'buildBetaDetails', id: 'bd-1', attributes: { externalBuildState: external, internalBuildState: 'IN_BETA_TESTING' }, rels: { build: 'b-1' } }); + }; + const invites = (): number => s.asc.all('betaTesterInvitations').length; + + test('pure: external needs BETA_APPROVED / IN_BETA_TESTING, internal READY_FOR_BETA_TESTING / IN_BETA_TESTING', () => { + const b = { attributes: { processingState: 'VALID' } }; + expect(installableBuild(b, { externalBuildState: 'READY_FOR_BETA_SUBMISSION' }, false)).toBe(false); + expect(installableBuild(b, { externalBuildState: 'WAITING_FOR_BETA_REVIEW' }, false)).toBe(false); + expect(installableBuild(b, { externalBuildState: 'BETA_APPROVED' }, false)).toBe(true); + expect(installableBuild(b, { externalBuildState: 'IN_BETA_TESTING' }, false)).toBe(true); + expect(installableBuild(b, { internalBuildState: 'READY_FOR_BETA_TESTING' }, true)).toBe(true); + expect(installableBuild({ attributes: { processingState: 'VALID', expired: true } }, { externalBuildState: 'BETA_APPROVED' }, false)).toBe(false); + expect(noInstallableBuildHint(['Friends'])).toBe('tester added; the invite goes out when a build in Friends passes Beta App Review (re-run with --invite then)'); + expect(noInstallableBuilds409(new Error('POST /v1/betaTesterInvitations -> 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS'))).toBe(true); + expect(noInstallableBuilds409(new Error('-> 409 ALREADY_EXISTS'))).toBe(false); + }); + + test('a new external group without an approved build: the tester is added, no invitation is sent, exit 0 with the hint', async () => { + group(); + buildIn('WAITING_FOR_BETA_REVIEW'); + const p = project(cfg, CSV); + const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes']))); + expect(r.exit).toBeNull(); + expect(r.err).toContain('tester added; the invite goes out when a build in Friends passes Beta App Review (re-run with --invite then)'); + expect(s.asc.all('betaTesters')).toHaveLength(1); + expect(invites()).toBe(0); + expect(writes(s.calls).some(c => c.path === '/v1/betaTesterInvitations')).toBe(false); + }); + + test('once a build passed Beta App Review, --invite sends the invitation', async () => { + group(); + const p = project(cfg, CSV); + await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes']))); + expect(invites()).toBe(0); + buildIn('BETA_APPROVED'); + const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes']))); + expect(r.exit).toBeNull(); + expect(invites()).toBe(1); + }); + + test('if the 409 still comes back (the build looked installable), it is the same case: warning, no throw, exit 0', async () => { + group(); + buildIn('BETA_APPROVED'); + const p = project(cfg, CSV); + // Apple's answer wins over our read: the double refuses the invitation as the live API did + s.faults.push({ method: 'POST', match: /\/v1\/betaTesterInvitations/, when: 'before', status: 409, code: 'STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS', detail: 'The tester cannot be invited because the groups they belong to have no installable builds.' }); + const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes']))); + expect(r.exit).toBeNull(); + expect(r.err).toContain('passes Beta App Review'); + expect(s.asc.all('betaTesters')).toHaveLength(1); + }); + + test('distribute --submit-review prints the hint for testers that are added but not invited', async () => { + group(); + buildIn('READY_FOR_BETA_SUBMISSION'); + const p = project(cfg, CSV); + await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--yes']))); + s.asc.all('buildBetaDetails')[0]!.attributes.processingState = 'x'; + s.asc.put({ type: 'preReleaseVersions', id: 'pre-1', attributes: { version: '1.0.0', platform: 'IOS' }, rels: { app: APP } }); + s.asc.all('builds')[0]!.rels.preReleaseVersion = 'pre-1'; + s.asc.all('builds')[0]!.rels.buildBetaDetail = 'bd-1'; + const r = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '7', '--group', 'Friends', '--submit-review', '--yes']))); + expect(r.out + r.err).toContain('tester added; the invite goes out when a build in Friends passes Beta App Review'); + }); +}); diff --git a/tests/build-sign.test.ts b/tests/build-sign.test.ts index 8a491dc..1b91c9f 100644 --- a/tests/build-sign.test.ts +++ b/tests/build-sign.test.ts @@ -408,6 +408,23 @@ describe('CLI dry runs (no store access)', () => { expect(j.exportOptions.method).toBe('app-store-connect'); expect(j.steps[0]).toContain('CURRENT_PROJECT_VERSION=42'); expect(j.steps[0]).toContain('-authenticationKeyPath $ASC_P8'); + // no --out: Xcode's DerivedData stays, nothing remapped + expect(j).toMatchObject({ derivedData: '(Xcode default)', pathFree: false }); + expect(j.steps[0]).not.toContain('-derivedDataPath'); + expect(j.steps[0]).not.toContain('prefix-map'); + }); + test('apple build --dry --json: --out makes DerivedData hermetic, --path-free adds the prefix maps', () => { + const d = project('cli-apple-dd'); + const r = cli(d, ['apple', 'build', '--dry', '--json', '--build-number', '42', '--out', 'out/ios', '--path-free']); + expect(r.exitCode).toBe(0); + const j = JSON.parse(r.stdout.toString()); + expect(j.pathFree).toBe(true); + expect(j.derivedData.split(String.fromCharCode(92)).join('/')).toMatch(/out\/ios\/DerivedData$/); + expect(j.steps[0]).toContain('-derivedDataPath'); + expect(j.steps[0]).toContain('OTHER_SWIFT_FLAGS='); + expect(j.steps[0]).toContain('-ffile-prefix-map='); + const e = cli(d, ['apple', 'build', '--dry', '--json', '--build-number', '42', '--out', 'out/ios', '--derived-data', 'dd']); + expect(JSON.parse(e.stdout.toString()).derivedData.split(String.fromCharCode(92)).join('/')).toMatch(/\/dd$/); }); test('play build --dry --json: task, versionCode, signing source', () => { const r = cli(project('cli-play'), ['play', 'build', '--dry', '--json', '--version-code', '12']); diff --git a/tests/capture-landscape.test.ts b/tests/capture-landscape.test.ts new file mode 100644 index 0000000..6768a76 --- /dev/null +++ b/tests/capture-landscape.test.ts @@ -0,0 +1,101 @@ +// `shots capture --device landscape`: desktop frames for Mac App Store / Chromebook screenshots from a web build. +// Dry runs and pure planning only: no browser, no simulator. +import { afterEach, describe, expect, test } from 'bun:test'; +import { rmSync } from 'node:fs'; +import type { ShipConfig } from '../cli/lib/config'; +import { planWeb } from '../cli/shots/capture-web'; +import { run as captureRun } from '../cli/shots/capture'; +import { shotsFor, validateCapture } from '../cli/shots/devices'; +import { frameDims, landscapeDims, rendersLandscape } from '../cli/shots/lib'; +import { BASE, capture, project, restore } from './helpers/store-mock'; + +afterEach(restore); + +const cap = { source: 'web', baseUrl: 'http://127.0.0.1:5173', shots: { + hero: { path: '/?scene=hero' }, wide: { path: '/?scene=wide', device: 'landscape' }, pad: { path: '/pad', device: 'tablet' }, both: { path: '/both', device: 'both' }, ph: { path: '/ph', device: 'phone' }, +} } as const; +const cfg = (over: Record = {}, shots: Record = {}): ShipConfig => + ({ ...BASE, ...over, shots: { frames: 'frames', copy: 'copy.json', scenes: [], capture: cap, ...shots } }) as unknown as ShipConfig; +const names = (c: Parameters[0], d: 'phone' | 'tablet' | 'landscape', all = false) => shotsFor(c, d, [], all).map(([n]) => n); + +describe('landscape device selection', () => { + test('phone / tablet unchanged; landscape only for explicit shots unless the project renders landscape', () => { + expect(names(cap, 'phone')).toEqual(['hero', 'both', 'ph']); + expect(names(cap, 'tablet')).toEqual(['pad', 'both']); + expect(names(cap, 'landscape')).toEqual(['wide']); + expect(names(cap, 'landscape', true)).toEqual(['hero', 'wide', 'both']); // not the explicit phone / tablet ones + }); + test('validateCapture accepts landscape and names it in the error', () => { + expect(validateCapture({ ...cap, shots: { a: { path: '/', device: 'landscape' } } })).toEqual([]); + expect(validateCapture({ ...cap, shots: { a: { path: '/', device: 'desktop' as 'both' } } }).join()).toContain('phone | tablet | both | landscape'); + }); + test('rendersLandscape follows the render rules (mac, chromebook, playTablets)', () => { + expect(rendersLandscape(cfg())).toBe(false); + expect(rendersLandscape(cfg({ apple: { ...BASE.apple!, mac: true } }))).toBe(true); + expect(rendersLandscape(cfg({ apple: undefined, play: { ...BASE.play!, chromebook: true } }))).toBe(true); + expect(rendersLandscape(cfg({}, { landscape: { playTablets: true } }))).toBe(true); + }); + test('landscapeDims: 1440 x 900 @2x default, shots.landscape.size overrides', () => { + expect(landscapeDims(cfg())).toEqual({ w: 1440, h: 900, dpr: 2, bar: 0 }); + expect(landscapeDims(cfg({}, { landscape: { size: { w: 1280, h: 800 } } }))).toEqual({ w: 1280, h: 800, dpr: 2, bar: 0 }); + expect(landscapeDims(cfg({}, { landscape: { size: { w: 1920, h: 1080, dpr: 1 } } }))).toEqual({ w: 1920, h: 1080, dpr: 1, bar: 0 }); + }); +}); + +describe('planWeb with landscape', () => { + test('jobs target /landscape/.png and the hash tracks the landscape viewport', () => { + const c = cfg(); + const base = { langs: ['en'], devices: ['landscape' as const], only: [], framesDir: '/f', build: 'b', allLandscape: true }; + const a = planWeb(cap, { ...base, dims: { ...frameDims(c), landscape: landscapeDims(c) } }); + expect(a.map(j => j.frame)).toEqual(['en/landscape/hero.png', 'en/landscape/wide.png', 'en/landscape/both.png']); + const b = planWeb(cap, { ...base, dims: { ...frameDims(c), landscape: { w: 1280, h: 800, dpr: 2, bar: 0 } } }); + expect(b[0]!.hash).not.toBe(a[0]!.hash); + }); +}); + +describe('capture --dry', () => { + const dry = async (config: ShipConfig, argv: string[]) => { + const p = project(config); + try { return await capture(() => captureRun(p.ctx(['--dry', ...argv], ['dry']))); } finally { rmSync(p.root, { recursive: true, force: true }); } + }; + test('--device landscape lists landscape targets, no phone / tablet', async () => { + const r = await dry(cfg(), ['--device', 'landscape', '--lang', 'en']); + expect(r.out).toContain('en/landscape/wide.png <- http://127.0.0.1:5173/?scene=wide'); + expect(r.out).toContain('en/landscape/hero.png'); // --device landscape captures every shot without a phone / tablet device + expect(r.out).not.toContain('en/phone/'); + expect(r.out).not.toContain('en/tablet/'); + expect(r.out).toContain('capture planned (dry run): 3 frames from web'); + }); + test('default devices add landscape only when the project renders a landscape size', async () => { + const plain = await dry(cfg(), ['--lang', 'en']); + expect(plain.out).not.toContain('en/landscape/hero.png'); + expect(plain.out).toContain('en/phone/hero.png'); + const mac = await dry(cfg({ apple: { ...BASE.apple!, mac: true } }), ['--lang', 'en']); + expect(mac.out).toContain('en/phone/hero.png'); + expect(mac.out).toContain('en/tablet/pad.png'); + for (const n of ['hero', 'wide', 'both']) expect(mac.out).toContain(`en/landscape/${n}.png`); + expect(mac.out).not.toContain('en/landscape/pad.png'); + expect(mac.out).not.toContain('en/landscape/ph.png'); + }); + test('shot selectors accept a landscape key and print the landscape viewport', async () => { + const c = cfg({ apple: { ...BASE.apple!, mac: true } }, { capture: { ...cap, shots: { hero: { path: '/', waitSelector: { phone: '#m', landscape: '#desk' } } } } }); + const r = await dry(c, ['--lang', 'en']); + expect(r.out).toContain('waitSelector on landscape 1440x900: #desk'); + expect(r.out).toContain('waitSelector on phone 440x902: #m'); + }); + test('ios-sim / android-emu skip landscape with a warning', async () => { + const mac = { apple: { ...BASE.apple!, mac: true } }; + const sim = await dry(cfg(mac, { capture: { source: 'ios-sim', ios: { bundleId: 'com.example.app', device: 'iPhone 17 Pro Max' }, shots: { hero: { path: '/', device: 'landscape' } } } }), ['--device', 'landscape', '--lang', 'en']); + expect(sim.err + sim.out).toContain('landscape frames (Mac, Chromebook) are not supported from ios-sim'); + expect(sim.out).toContain('0 frames'); + const and = await dry(cfg(mac, { capture: { source: 'android-emu', android: { package: 'com.example.app' }, shots: { hero: { path: '/' } } } }), ['--lang', 'en']); + expect(and.err + and.out).toContain('not supported from android-emu'); + expect(and.out).not.toContain('landscape/hero'); + }); + test('an unknown device is refused', async () => { + const p = project(cfg()); + try { + await expect(captureRun(p.ctx(['--dry', '--device', 'desktop'], ['dry']))).rejects.toThrow('--device: phone | tablet | landscape'); + } finally { rmSync(p.root, { recursive: true, force: true }); } + }); +}); diff --git a/tests/cli-shim.test.ts b/tests/cli-shim.test.ts new file mode 100644 index 0000000..c515923 --- /dev/null +++ b/tests/cli-shim.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ensureCliShim, onPath, SHIM_MARKER, shimPlan } from '../cli/lib/cli-shim'; + +describe('desktop terminal launcher', () => { + test('per platform: file, content, PATH folder, profile', () => { + const w = shimPlan({ exe: 'C:\\Users\\you\\AppData\\Local\\storebound\\storebound.exe', platform: 'win32', home: 'C:\\Users\\you', env: { LOCALAPPDATA: 'C:\\Users\\you\\AppData\\Local' } }); + expect(w.file.endsWith('storebound.cmd')).toBe(true); + expect(w.content).toContain('"C:\\Users\\you\\AppData\\Local\\storebound\\storebound.exe" %*'); + expect(w.skip).toBeNull(); + const m = shimPlan({ exe: "/Applications/store'bound.app/Contents/MacOS/storebound", platform: 'darwin', home: '/Users/you', env: {} }); + expect(m.content).toContain(`exec '/Applications/store'\\''bound.app/Contents/MacOS/storebound' "$@"`); + expect(m.profile!.endsWith('.zprofile')).toBe(true); + expect(shimPlan({ exe: '/usr/bin/storebound', platform: 'linux', home: '/home/you', env: {} }).profile!.endsWith('.profile')).toBe(true); + }); + test('skips AppImage runs and the opt-out', () => { + expect(shimPlan({ exe: '/tmp/.mount_sbX/usr/bin/storebound', platform: 'linux', home: '/h', env: {} }).skip).toContain('AppImage'); + expect(shimPlan({ exe: '/usr/bin/storebound', platform: 'linux', home: '/h', env: { STOREBOUND_NO_CLI_SHIM: '1' } }).skip).toContain('STOREBOUND_NO_CLI_SHIM'); + }); + test('onPath: trailing slashes, case on Windows only', () => { + expect(onPath('C:\\A\\bin', 'c:\\a\\bin\\;D:\\x', 'win32')).toBe(true); + expect(onPath('/h/.local/bin', '/usr/bin:/h/.local/bin/', 'linux')).toBe(true); + expect(onPath('/h/.local/bin', '/usr/bin:/H/.local/bin', 'linux')).toBe(false); + }); + test('writes once, rewrites a moved app, never touches a file that is not ours, adds the profile line once', () => { + const home = mkdtempSync(join(tmpdir(), 'sb-shim-')); + const env = { PATH: '/usr/bin' }; + const first = ensureCliShim('/opt/a/storebound', 'linux', env, home); + expect(first.join('\n')).toContain('terminal launcher written'); + expect(first.join('\n')).toContain('added ~/.local/bin to PATH'); + const file = join(home, '.local', 'bin', 'storebound'); + expect(readFileSync(file, 'utf8')).toContain(SHIM_MARKER); + expect(ensureCliShim('/opt/a/storebound', 'linux', env, home).join()).toContain('up to date'); + expect(ensureCliShim('/opt/b/storebound', 'linux', env, home).join()).toContain('written'); + expect(readFileSync(file, 'utf8')).toContain('/opt/b/storebound'); + expect(readFileSync(join(home, '.profile'), 'utf8').split(SHIM_MARKER).length - 1).toBe(2 - 1); + writeFileSync(file, '#!/bin/sh\nexec npm-storebound "$@"\n'); + expect(ensureCliShim('/opt/c/storebound', 'linux', env, home).join()).toContain('is not ours'); + expect(readFileSync(file, 'utf8')).toContain('npm-storebound'); + if (process.platform === 'win32') return; // a C:\ temp folder has a colon: no POSIX PATH can name it + const home2 = mkdtempSync(join(tmpdir(), 'sb-shim-')); + mkdirSync(join(home2, '.local', 'bin'), { recursive: true }); + expect(ensureCliShim('/opt/a/storebound', 'linux', { PATH: `/usr/bin:${join(home2, '.local', 'bin')}` }, home2).join()).not.toContain('PATH'); + }); +}); diff --git a/tests/console-play-plans.test.ts b/tests/console-play-plans.test.ts index c65df5d..1210897 100644 --- a/tests/console-play-plans.test.ts +++ b/tests/console-play-plans.test.ts @@ -30,7 +30,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch 'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health', 'play-games-services': 'play-games-services/setup', 'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production', 'play-publish-changes': 'publishing', }; - for (const form of PLAN_ONLY_FORM_IDS) { + for (const form of PLAN_ONLY_FORM_IDS.filter(f => f.startsWith('play-'))) { test(form, () => { const p = consolePlan(form, base); expect(p).toMatchObject({ form, store: 'play', fillable: true }); diff --git a/tests/credential-broker.test.ts b/tests/credential-broker.test.ts new file mode 100644 index 0000000..e99e8e2 --- /dev/null +++ b/tests/credential-broker.test.ts @@ -0,0 +1,339 @@ +// Google access through a credential broker (cli/lib/broker.ts, playToken in cli/lib/google-auth.ts), against a local +// Bun.serve fake broker: token minting, caching, single flight and expiry, fallback to the local key, the package's +// Play permissions, the missing-grant guardrail (preflight and Google's own 403), key file permissions, and that no +// key or token shows up in any output. +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { generateKeyPairSync } from 'node:crypto'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type BrokerConfig, brokerConfig, brokerKey, brokerPlayInfo, BrokerRefused, brokerToken, checkBrokerUrl, clearBrokerCache, playGaps, playPreflight, redact } from '../cli/lib/broker'; +import { playCredentials, playToken } from '../cli/lib/google-auth'; +import { accessError, GoogleAccessError } from '../cli/lib/google-access'; +import { playClient } from '../cli/play/client'; +import { doctor, playLine } from '../cli/doctor'; + +const PKG = 'com.example.game'; +const ACCOUNT = 'publisher@sb-test.example.com'; +const API_KEY = 'brokerkey_0123456789abcdef'; +const ENV = ['PLAY_SERVICE_ACCOUNT', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'STOREBOUND_BROKER', 'STOREBOUND_BROKER_URL', 'STOREBOUND_BROKER_KEY_FILE', 'STOREBOUND_BROKER_CONFIG', + 'STOREBOUND_CREDENTIALS_DIR', 'STOREBOUND_PROFILE', 'STOREBOUND_BROKER_TIMEOUT_MS'] as const; +const saved = Object.fromEntries(ENV.map(k => [k, process.env[k]])); + +interface Seen { method: string; path: string; auth: string | null; body: unknown } +let seen: Seen[] = []; +let mints = 0; +/** What the fake broker answers; tests change it. */ +let route: (s: Seen) => Response | Promise; +const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } }); +const nowS = () => Math.floor(Date.now() / 1000); +const defaultRoute = (s: Seen): Response => { + if (s.path === '/api/google/token') { mints++; return json({ accessToken: `ya29.fake-${mints}`, expiresAt: nowS() + 3600, clientEmail: ACCOUNT, scopes: ['androidpublisher'] }); } + if (s.path === `/api/google/play/${PKG}/storebound`) return json(INFO); + return json({ error: 'err_not_found' }, 404); +}; +const INFO = { + packageName: PKG, developerId: '5550000000000000001', account: ACCOUNT, + accountPermissions: { developer: [], app: ['CAN_MANAGE_PUBLIC_LISTING', 'CAN_MANAGE_ORDERS'] }, + grantsCheckedAt: 1761390000, + projects: [{ projectId: 'sb-test-project', projectNumber: '100000000001', enabledApis: ['androidpublisher.googleapis.com'], billing: 'linked' }], + sdkConfigs: [], +}; + +let server: ReturnType; +let tmp: string, root: string, keyFile: string; +const realFetch = globalThis.fetch; + +beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(req) { + const u = new URL(req.url); + const text = await req.text(); + const s: Seen = { method: req.method, path: decodeURIComponent(u.pathname), auth: req.headers.get('authorization'), body: text ? JSON.parse(text) : null }; + seen.push(s); + return route(s); + }, + }); +}); +afterAll(() => { server.stop(true); }); + +function writeBroker(o: Record = {}): void { + writeFileSync(join(tmp, 'broker.json'), JSON.stringify({ url: `http://127.0.0.1:${server.port}`, apiKeyFile: keyFile, account: ACCOUNT, ...o })); +} +function localKey(): void { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: ACCOUNT, private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' }); +} +/** fetch double for Google only (the local key's token exchange, the Play API); the fake broker is reached for real. */ +function fakeGoogle(api: (url: string, init?: RequestInit) => Response = () => json({})): string[] { + const calls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'local-file-token', expires_in: 3600 }); + if (/googleapis\.com/.test(url)) { calls.push(`${init?.method ?? 'GET'} ${url}`); return api(url, init); } + return realFetch(input as Request, init); + }) as typeof fetch; + return calls; +} + +beforeEach(() => { + for (const k of ENV) delete process.env[k]; + tmp = mkdtempSync(join(tmpdir(), 'sb-broker-')); + root = join(tmp, 'project'); + mkdirSync(root, { recursive: true }); + keyFile = join(tmp, 'broker.key'); + writeFileSync(keyFile, API_KEY + '\n', { mode: 0o600 }); + process.env.STOREBOUND_BROKER_CONFIG = join(tmp, 'broker.json'); + process.env.STOREBOUND_CREDENTIALS_DIR = join(tmp, 'credentials'); + process.env.STOREBOUND_PROFILE = ''; + seen = []; mints = 0; route = defaultRoute; + clearBrokerCache(); +}); +afterEach(() => { + globalThis.fetch = realFetch; + rmSync(tmp, { recursive: true, force: true }); + for (const k of ENV) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } +}); + +describe('config', () => { + test('broker.json, env overrides, https only, per-profile opt-out', () => { + expect(brokerConfig(root)).toBeNull(); + writeBroker(); + const c = brokerConfig(root)!; + expect(c.url).toBe(`http://127.0.0.1:${server.port}`); + expect(c.keyFile).toBe(keyFile); + expect(c.account).toBe(ACCOUNT); + // PLAY_SERVICE_ACCOUNT_EMAIL (here a broker credential id) wins over broker.json + process.env.PLAY_SERVICE_ACCOUNT_EMAIL = 'cred-play-1'; + expect(brokerConfig(root)!.account).toBe('cred-play-1'); + delete process.env.PLAY_SERVICE_ACCOUNT_EMAIL; + process.env.STOREBOUND_BROKER_URL = 'https://broker.example.com/'; + expect(brokerConfig(root)!.url).toBe('https://broker.example.com'); + expect(brokerConfig(root)!.from).toBe('env'); + process.env.STOREBOUND_BROKER = 'off'; + expect(brokerConfig(root)).toBeNull(); + delete process.env.STOREBOUND_BROKER; + expect(() => checkBrokerUrl('http://broker.example.com')).toThrow(/https only/); + // a profile that keeps its own key + const creds = join(tmp, 'credentials'); + mkdirSync(creds, { recursive: true }); + writeFileSync(join(creds, 'p1.json'), JSON.stringify({ broker: false })); + process.env.STOREBOUND_PROFILE = 'p1'; + expect(brokerConfig(root)).toBeNull(); + }); + + test('a broken broker.json is an error, not a silent skip', () => { + writeFileSync(join(tmp, 'broker.json'), '{ not json'); + expect(() => brokerConfig(root)).toThrow(/broker\.json/); + }); +}); + +describe('key file permissions', () => { + test('group / world readable refused on POSIX, accepted on Windows; the key is never in the message', () => { + writeBroker(); + const cfg = brokerConfig(root)!; + if (process.platform !== 'win32') { + chmodSync(keyFile, 0o644); + expect(() => brokerKey(cfg)).toThrow(/chmod 600/); + chmodSync(keyFile, 0o600); + expect(brokerKey(cfg)).toBe(API_KEY); + } else { + // Windows reports 0o666 for every writable file: the POSIX rule is checked by naming the platform + let msg = ''; + try { brokerKey(cfg, 'linux'); } catch (e) { msg = (e as Error).message; } + expect(msg).toMatch(/readable by group or others .*chmod 600/); + expect(msg).not.toContain(API_KEY); + expect(brokerKey(cfg, 'win32')).toBe(API_KEY); + } + rmSync(keyFile); + expect(() => brokerKey(cfg)).toThrow(/not found/); + }); +}); + +describe('token minting', () => { + test('one mint, cached until 5 minutes before expiry, scoped request with the bearer key', async () => { + writeBroker(); + const t = playToken(root); + expect(await t()).toBe('ya29.fake-1'); + expect(await t()).toBe('ya29.fake-1'); + expect(mints).toBe(1); + expect(t.source()).toBe('broker'); + expect(t.principal()).toBe(ACCOUNT); + const mint = seen.find(s => s.path === '/api/google/token')!; + expect(mint.method).toBe('POST'); + expect(mint.auth).toBe(`Bearer ${API_KEY}`); + expect(mint.body).toEqual({ account: ACCOUNT, scopes: ['androidpublisher'] }); + }); + + test('parallel callers share one request (single flight)', async () => { + writeBroker(); + route = async s => { await Bun.sleep(50); return defaultRoute(s); }; + const t = playToken(root); + const all = await Promise.all(Array.from({ length: 6 }, () => t())); + expect(new Set(all)).toEqual(new Set(['ya29.fake-1'])); + expect(mints).toBe(1); + }); + + test('a token close to expiry is minted again', async () => { + writeBroker(); + const cfg = brokerConfig(root)!; + let clock = Date.now(); + expect(await brokerToken(cfg, ACCOUNT, 'androidpublisher', () => clock)).toBe('ya29.fake-1'); + clock += 3000_000; // 50 min later: 10 min left, still cached + expect(await brokerToken(cfg, ACCOUNT, 'androidpublisher', () => clock)).toBe('ya29.fake-1'); + clock += 400_000; // under 5 min left + expect(await brokerToken(cfg, ACCOUNT, 'androidpublisher', () => clock)).toBe('ya29.fake-2'); + expect(mints).toBe(2); + }); + + test('the account comes from the broker for the package when nothing names it', async () => { + writeBroker({ account: undefined }); + const t = playToken(root, undefined, { packageName: PKG }); + expect(await t()).toBe('ya29.fake-1'); + expect((seen.find(s => s.path === '/api/google/token')!.body as { account: string }).account).toBe(ACCOUNT); + }); + + test('Reporting has no broker scope: the local key serves it, and without one the error says so', () => { + writeBroker(); + expect(() => playToken(root, 'https://www.googleapis.com/auth/playdeveloperreporting')).toThrow(/hands out no such scope/); + }); +}); + +describe('fallback to the local key', () => { + test('broker not answering: the local key, one warning, source "file"', async () => { + localKey(); + fakeGoogle(); + writeFileSync(join(tmp, 'broker.json'), JSON.stringify({ url: 'http://127.0.0.1:9', apiKeyFile: keyFile })); + process.env.STOREBOUND_BROKER_TIMEOUT_MS = '2000'; + const warnings: string[] = []; + const t = playToken(root, undefined, { warn: m => warnings.push(m) }); + expect(await t()).toBe('local-file-token'); + expect(await t()).toBe('local-file-token'); + expect(t.source()).toBe('file'); + expect(warnings.length).toBe(1); + expect(warnings[0]).toMatch(/did not answer.*local service account key/); + }); + + test('broker 503: the local key', async () => { + localKey(); + fakeGoogle(); + writeBroker(); + route = () => json({ error: 'down' }, 503); + const t = playToken(root, undefined, { warn: () => {} }); + expect(await t()).toBe('local-file-token'); + }); + + test('broker refusing the API key (401): no fallback, no retry, the key lacks google:token', async () => { + localKey(); + fakeGoogle(); + writeBroker(); + route = () => json({ error: 'err_scope', echo: `Bearer ${API_KEY}` }, 401); + const t = playToken(root, undefined, { warn: () => {} }); + const e = await t().then(() => null, x => x as Error); + expect(e).toBeInstanceOf(BrokerRefused); + expect(e!.message).toMatch(/lacks the google:token scope/); + expect(e!.message).not.toContain(API_KEY); + expect(seen.filter(s => s.path === '/api/google/token').length).toBe(1); + }); + + test('no broker: the local key as before; neither: the PLAY_SERVICE_ACCOUNT error', async () => { + expect(() => playToken(root)).toThrow(/PLAY_SERVICE_ACCOUNT is not set/); + localKey(); + fakeGoogle(); + const t = playToken(root); + expect(await t()).toBe('local-file-token'); + expect(t.source()).toBe('file'); + expect(seen.length).toBe(0); + }); +}); + +describe('Play permissions from the broker', () => { + test('grants and APIs read; gaps name the permission, the API and their links', async () => { + writeBroker(); + const r = await brokerPlayInfo(brokerConfig(root)!, PKG); + expect(r.kind).toBe('ok'); + if (r.kind !== 'ok') return; + const gaps = playGaps(r.info, ['upload', 'listing', 'games']); + expect(gaps.map(g => g.name)).toEqual(['CAN_MANAGE_TRACK_APKS', 'gamesconfiguration.googleapis.com']); + expect(gaps[0]!.url).toBe('https://play.google.com/console/u/0/developers/5550000000000000001/users-and-permissions'); + expect(gaps[0]!.fix).toMatch(/google_play_grant/); + expect(gaps[1]!.url).toBe('https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=sb-test-project'); + // read once per run and package + await brokerPlayInfo(brokerConfig(root)!, PKG); + expect(seen.filter(s => s.path.endsWith('/storebound')).length).toBe(1); + }); + + test('404: register the package, not "no access"', async () => { + writeBroker(); + const r = await brokerPlayInfo(brokerConfig(root)!, 'com.example.other'); + expect(r.kind).toBe('not-registered'); + if (r.kind === 'not-registered') expect(r.message).toMatch(/register the package .*not a missing Play access/); + }); + + test('preflight: a write stops before any Play call with the exact grant; a dry run only warns', async () => { + writeBroker(); + const google = fakeGoogle(); + const e = await playPreflight('play upload', root, PKG, { dry: false, warn: () => {} }).catch(x => x as Error); + expect(e).toBeInstanceOf(Error); + expect((e as Error).message).toMatch(/stopped before any call to Google Play/); + expect((e as Error).message).toMatch(/CAN_MANAGE_TRACK_APKS for com\.example\.game/); + const warned: string[] = []; + expect((await playPreflight('play upload', root, PKG, { dry: true, warn: m => warned.push(m) })).length).toBe(1); + expect(warned[0]).toMatch(/CAN_MANAGE_TRACK_APKS/); + // granted: nothing; a command without needs: nothing + expect(await playPreflight('play listing', root, PKG, { dry: false, warn: () => {} })).toEqual([]); + expect(await playPreflight('play status', root, PKG, { dry: false, warn: () => {} })).toEqual([]); + expect(google).toEqual([]); + }); +}); + +describe('Google 403 guardrail', () => { + const DISABLED = `GET /edits -> 403 {"error":{"code":403,"message":"Google Play Android Developer API has not been used in project 100000000001 before or it is disabled.","status":"PERMISSION_DENIED","details":[{"reason":"SERVICE_DISABLED","metadata":{"service":"androidpublisher.googleapis.com","consumer":"projects/sb-test-project"}}]}}`; + test('SERVICE_DISABLED: the API and its enable link, never retried', async () => { + const e = await accessError(DISABLED, { app: PKG }); + expect(e).toBeInstanceOf(GoogleAccessError); + expect(e!.noRetry).toBe(true); + expect(e!.message).toContain('https://console.cloud.google.com/apis/library/androidpublisher.googleapis.com?project=sb-test-project'); + }); + + test('PERMISSION_DENIED through the Play client with broker tokens: one call, the grants the broker lists', async () => { + writeBroker(); + const google = fakeGoogle(() => json({ error: { code: 403, message: 'The caller does not have permission', status: 'PERMISSION_DENIED' } }, 403)); + const play = await playClient(root, PKG); + const e = await play.call('POST', `${play.API}/edits`, {}).then(() => null, x => x as Error); + expect(e).toBeInstanceOf(GoogleAccessError); + expect(google.length).toBe(1); + expect(e!.message).toMatch(/no access to com\.example\.game in Play Console/); + expect(e!.message).toMatch(/credential broker lists CAN_MANAGE_ORDERS, CAN_MANAGE_PUBLIC_LISTING/); + expect(play.credentialSource()).toBe('broker'); + }); +}); + +describe('no secret in output', () => { + test('redact removes tokens, bearer values and the key', () => { + expect(redact(`Authorization: Bearer ${API_KEY} token ya29.a0AfH6SM-x_y.z`, API_KEY)).toBe('Authorization: Bearer *** token ya29.***'); + }); + + test('doctor and the credential summary carry the source, never the key or a token', async () => { + writeBroker(); + await playToken(root)(); + const p = playCredentials(root); + expect(p.source).toBe('broker'); + const d = doctor(root); + const out = JSON.stringify(d) + playLine(d.play); + expect(d.play.source).toBe('broker'); + expect(playLine(d.play)).toMatch(/tokens from the credential broker/); + expect(out).not.toContain(API_KEY); + expect(out).not.toContain('ya29.'); + expect(out).not.toContain(ACCOUNT); // masked + }); + + test('a broker error body echoing the key or a token is redacted', async () => { + writeBroker(); + route = () => new Response(`upstream failed for Bearer ${API_KEY} ya29.secret-token`, { status: 502 }); + const e = await brokerToken(brokerConfig(root)! as BrokerConfig, ACCOUNT, 'androidpublisher').then(() => null, x => x as Error); + expect(e!.message).not.toContain(API_KEY); + expect(e!.message).not.toContain('ya29.secret-token'); + }); +}); diff --git a/tests/doctor-tcc.test.ts b/tests/doctor-tcc.test.ts new file mode 100644 index 0000000..00b78ec --- /dev/null +++ b/tests/doctor-tcc.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'bun:test'; +import { protectedInstall } from '../cli/doctor'; + +test('a storebound running from ~/Desktop (a bun link to a checkout) is named; npm / bun global installs are fine', () => { + expect(protectedInstall('/Users/you/Desktop/storebound/dist/storebound.mjs', '/Users/you', 'darwin')).toBe('~/Desktop'); + expect(protectedInstall('/Users/you/Documents/x/cli/index.ts', '/Users/you', 'darwin')).toBe('~/Documents'); + expect(protectedInstall('/Users/you/.bun/install/global/node_modules/storebound/dist/storebound.mjs', '/Users/you', 'darwin')).toBeNull(); + expect(protectedInstall('/Users/you/Desktop/x.mjs', '/Users/you', 'linux')).toBeNull(); +}); diff --git a/tests/flow-dry.test.ts b/tests/flow-dry.test.ts new file mode 100644 index 0000000..7dd5ccc --- /dev/null +++ b/tests/flow-dry.test.ts @@ -0,0 +1,100 @@ +// storebound flow, dry runs: a step whose input (the IPA / AAB, the uploaded build) only a real earlier step makes is +// planned, not failed; a real run is untouched. The beta template: required VERSION, GROUP empty = the config's internal group. +import { afterAll, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type DryPending, evalIf, expand, flowsOfFile, missingParams, noteDry, planStep, plannedForDryInput, substitute, validateFlows } from '../cli/lib/flow'; +import { parseYaml } from '../cli/lib/yaml'; +import { readFileSync } from 'node:fs'; + +const ROOT = join(import.meta.dir, '..'); +const CLI = join(ROOT, 'cli', 'index.ts'); +const tmp = mkdtempSync(join(tmpdir(), 'storebound-flow-dry-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); +const sb = (cwd: string, ...args: string[]) => Bun.spawnSync([process.execPath, CLI, ...args], { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' }); +const C = { apple: true, play: true, exists: () => false }; + +const load = (name: string) => flowsOfFile(`templates/workflows/${name}.yml`, parseYaml(readFileSync(join(ROOT, 'templates', 'workflows', `${name}.yml`), 'utf8')), name).flows[0]!; + +/** What the runner decides per step in a mode: 'ran' (storebound is called), 'planned' (dry input missing) or 'skipped'. */ +function simulate(name: string, mode: 'dry' | 'apply', vars: Record) { + const f = load(name); + const pending: DryPending = { built: new Set(), uploaded: new Set() }; + const out: Record = {}; + for (const s of expand(f, new Map([[f.name, f]]), 'steps').steps) { + const p = planStep(s, 'steps', mode, vars, C); + if (p.skip) { out[s.id] = { how: 'skipped', argv: p.argv, owner: p.owner }; continue; } + const why = plannedForDryInput(p, mode, pending); + if (why) { out[s.id] = { how: 'planned', argv: p.argv }; continue; } + noteDry(p, pending); + out[s.id] = { how: 'ran', argv: p.argv }; + } + return out; +} + +describe('dry flows plan what a dry earlier step cannot produce', () => { + test('beta: dry build then upload (and wait, distribute) are planned, reads and checks still run', () => { + const r = simulate('beta', 'dry', { VERSION: '1.2.0', GROUP: '', BUILD: '7', 'config.testflight.internal': 'Team' }); + expect(r['build']).toMatchObject({ how: 'ran', argv: ['apple', 'build', '--version', '1.2.0', '--build-number', '7', '--dry'] }); + expect(r['upload']!.how).toBe('planned'); + expect(r['wait']!.how).toBe('planned'); + expect(r['distribute-internal']!.how).toBe('planned'); + expect(r['play-build']!.how).toBe('ran'); + expect(r['play-upload']!.how).toBe('planned'); + expect(r['check']!.how).toBe('ran'); + }); + + test('a real run is untouched: upload with no IPA still runs (and fails inside the command)', () => { + const r = simulate('beta', 'apply', { VERSION: '1.2.0', GROUP: '', BUILD: '7', 'config.testflight.internal': 'Team' }); + expect(r['upload']).toMatchObject({ how: 'ran', argv: ['apple', 'upload', '--no-wait', '--build', '7', '--allow-existing'] }); + expect(r['play-upload']!.how).toBe('ran'); + }); + + test('an upload of a named file, or without a dry build before it, is not planned away', () => { + const pending: DryPending = { built: new Set(['apple']), uploaded: new Set() }; + const up = planStep({ id: 'u', run: 'apple upload build/App.ipa' }, 'steps', 'dry', {}, C); + expect(plannedForDryInput(up, 'dry', pending)).toBeNull(); + const fresh: DryPending = { built: new Set(), uploaded: new Set() }; + expect(plannedForDryInput(planStep({ id: 'u', run: 'apple upload --no-wait' }, 'steps', 'dry', {}, C), 'dry', fresh)).toBeNull(); + }); + + test('release: the macOS pkg is tracked apart from the iOS IPA', () => { + const r = simulate('release', 'dry', { VERSION: '2.0.0', MAC_TAURI: 'src-tauri', PLAY_TRACK: 'beta', BUILD: '9' }); + expect(r['build']!.how).toBe('ran'); + expect(r['upload']!.how).toBe('planned'); + expect(r['mac-build']!.argv).toContain('--dry'); + expect(r['mac-upload']!.how).toBe('planned'); + expect(r['play-upload']!.argv).toEqual(['play', 'upload', '--track', 'beta', '--dry']); + const off = simulate('release', 'dry', { VERSION: '2.0.0', MAC_TAURI: '', PLAY_TRACK: 'internal', BUILD: '9' }); + expect(off['mac-build']!.how).toBe('skipped'); + expect(off['mac-upload']!.how).toBe('skipped'); + expect(off['promote']!.owner).toContain('internal'); + }); +}); + +describe('beta template defaults', () => { + test('VERSION is required, GROUP defaults to empty and falls to the internal group of the config', () => { + const f = load('beta'); + expect(f.params['VERSION']).toBe('?'); + expect(f.params['GROUP']).toBe(''); + expect(missingParams({ ...f.params })).toEqual(['VERSION']); + expect(missingParams({ ...f.params, VERSION: '1.0.0' })).toEqual([]); + const steps = expand(f, new Map([[f.name, f]]), 'steps').steps; + const by = (v: Record) => steps.filter(s => s.id.startsWith('distribute') || s.id === 'no-group') + .filter(s => evalIf(substitute(s.if ?? '', v), C)).map(s => s.id); + expect(by({ GROUP: '', 'config.testflight.internal': 'Team' })).toEqual(['distribute-internal']); + expect(by({ GROUP: 'QA', 'config.testflight.internal': 'Team' })).toEqual(['distribute']); + expect(by({ GROUP: '', 'config.testflight.internal': '' })).toEqual(['no-group']); + expect(validateFlows([f, load('release')])).toEqual([]); + }); + + test('flow run beta without VERSION fails early with a clear message', () => { + const dir = join(tmp, 'req'); + mkdirSync(join(dir, 'store', 'workflows'), { recursive: true }); + writeFileSync(join(dir, 'store', 'workflows', 'beta.yml'), readFileSync(join(ROOT, 'templates', 'workflows', 'beta.yml'), 'utf8')); + const p = sb(dir, 'flow', 'run', 'beta'); + expect(p.exitCode).not.toBe(0); + expect(p.stderr.toString() + p.stdout.toString()).toContain('beta needs VERSION='); + }); +}); diff --git a/tests/game-services.test.ts b/tests/game-services.test.ts index 636bcd8..a365d6a 100644 --- a/tests/game-services.test.ts +++ b/tests/game-services.test.ts @@ -8,7 +8,7 @@ import { capabilitySpecs, planCapabilities } from '../cli/lib/signing'; import { gamesPrivacyFindings, gcFindings, gcLocaleMap, liveGcLocales, suggestLocaleMap, pgsFindings, planGameCenter, playAchievementBody, playLeaderboardBody, readPgsProject, validateGames } from '../cli/lib/game-services'; import { consolePlan } from '../cli/lib/console-plan'; import { verifyText } from '../cli/lib/console-verify'; -import { gameServicesFindings, type GameServicesStatus } from '../cli/games-status'; +import { gameItemRows, gameServicesFindings, type GameServicesStatus } from '../cli/games-status'; import { run as gameCenter } from '../cli/apple/game-center'; import { run as playGames } from '../cli/play/games'; import { run as appleSubmit } from '../cli/apple/submit'; @@ -265,6 +265,7 @@ describe('validate findings', () => { describe('apple submit with a Game Center build (live 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED)', () => { const fresh = () => { s.asc.gameCenterBuild = true; + s.asc.put({ type: 'appStoreVersions', id: 'ver-0', attributes: { versionString: '0.9.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } }); // an update, not a first release s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } }); s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); }; @@ -298,3 +299,15 @@ describe('apple submit with a Game Center build (live 409 STATE_ERROR.BUILD_INDI expect(s.asc.all('gameCenterAppVersions')).toHaveLength(1); }); }); + +describe('games status rows (desktop Players section)', () => { + test('one row per item, store states from the plans, null when a store was not read', () => { + const g = { leaderboards: [{ id: 'com.example.lb', locales: { en: { name: 'Best' } } }], achievements: [{ id: 'com.example.a1', points: 10, hidden: true, platforms: ['android' as const], locales: { en: { name: 'First', description: 'd' } } }] }; + const plan = [{ kind: 'leaderboard' as const, id: 'com.example.lb', action: 'update' as const, attributes: {}, changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' as const, attributes: {}, changed: [] }] }]; + const rows = gameItemRows(g, plan, null); + expect(rows.map(r => [r.kind, r.id, r.name, r.ios, r.android])).toEqual([['leaderboard', 'com.example.lb', 'Best', true, true], ['achievement', 'com.example.a1', 'First', false, true]]); + expect(rows[0]!.apple).toEqual({ action: 'update', changed: ['scoreSortType'], locales: [{ locale: 'en-US', action: 'create' }] }); + expect(rows[0]!.play).toBeNull(); + expect(rows[1]).toMatchObject({ points: 10, hidden: true, apple: null }); + }); +}); diff --git a/tests/gradle-dir.test.ts b/tests/gradle-dir.test.ts new file mode 100644 index 0000000..ebb5836 --- /dev/null +++ b/tests/gradle-dir.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pickGradle } from '../cli/lib/gradle'; + +test('an absolute play.build.dir / --dir stays absolute (it lost its leading slash under the root)', () => { + const base = mkdtempSync(join(tmpdir(), 'gdir-')); + try { + const root = join(base, 'store'), android = join(base, 'other', 'apps', 'native', 'android'); + mkdirSync(root, { recursive: true }); mkdirSync(join(android, 'app'), { recursive: true }); + writeFileSync(join(android, 'settings.gradle'), "include ':app'\n"); + expect(resolve(pickGradle(root, { dir: android }).dir)).toBe(resolve(android)); + expect(resolve(pickGradle(root, { dir: '../other/apps/native/android' }).dir)).toBe(resolve(android)); + } finally { rmSync(base, { recursive: true, force: true }); } +}); diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index bf5c880..f5497e9 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -15,6 +15,7 @@ const UPLOAD_HOST = 'https://upload.example.com/'; /** list relationship -> resource type of its items (parent type / relationship name); `one` for to-one relationships. */ const LISTS: Record = { + 'apps/betaAppLocalizations': { type: 'betaAppLocalizations' }, 'apps/inAppPurchasesV2': { type: 'inAppPurchases' }, 'inAppPurchases/inAppPurchaseLocalizations': { type: 'inAppPurchaseLocalizations' }, 'inAppPurchases/iapPriceSchedule': { type: 'inAppPurchasePriceSchedules', one: true }, @@ -76,6 +77,10 @@ const LISTS: Record = { 'apps/promotedPurchases': { type: 'promotedPurchases' }, 'subscriptions/winBackOffers': { type: 'winBackOffers' }, 'betaGroups/betaTesters': { type: 'betaTesters' }, + 'betaGroups/builds': { type: 'builds' }, + 'builds/buildBetaDetail': { type: 'buildBetaDetails', one: true }, + 'builds/betaAppReviewSubmission': { type: 'betaAppReviewSubmissions', one: true }, + 'buildBundles/buildBundleFileSizes': { type: 'buildBundleFileSizes' }, 'apps/analyticsReportRequests': { type: 'analyticsReportRequests' }, 'apps/assetLibrary': { type: 'appAssetLibraries', one: true }, 'appAssetLibraries/images': { type: 'appAssetLibraryImages' }, @@ -162,6 +167,21 @@ export class FakeAsc { return { type: r.type, id: r.id, attributes: r.attributes, relationships }; } + /** Whether a group of the tester has a build its testers can install (external: BETA_APPROVED / IN_BETA_TESTING; internal: READY_FOR_BETA_TESTING / IN_BETA_TESTING). */ + hasInstallableBuild(testerId: string): boolean { + const gids = [this.res.get(testerId)?.rels.betaGroups ?? []].flat(); + return gids.some(gid => { + const g = this.res.get(gid); + if (!g) return false; + const internal = g.attributes.isInternalGroup === true; + const builds = internal && g.attributes.hasAccessToAllBuilds === true ? this.all('builds') : this.children(gid, 'builds'); + return builds.some(b => { + const st = String(this.children(b.id, 'buildBetaDetails')[0]?.attributes[internal ? 'internalBuildState' : 'externalBuildState'] ?? ''); + return internal ? ['READY_FOR_BETA_TESTING', 'IN_BETA_TESTING'].includes(st) : ['BETA_APPROVED', 'IN_BETA_TESTING'].includes(st); + }); + }); + } + children(parent: string, type: string): R[] { return this.all(type).filter(r => Object.values(r.rels).some(v => (Array.isArray(v) ? v.includes(parent) : v === parent))); } @@ -205,6 +225,11 @@ export class FakeAsc { if (v && v.attributes.state === 'DEVELOPER_REJECTED' && v.type !== 'appStoreVersions') v.attributes.state = 'READY_FOR_REVIEW'; main.attributes.state ??= 'READY_FOR_REVIEW'; } + // seen live: inviting a tester whose groups have no installable build (an external build is installable after Beta App + // Review) is 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS + if (d.type === 'betaTesterInvitations' && !this.hasInstallableBuild(String(main.rels.betaTester))) + return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '409', code: 'STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS', title: 'There are no installable builds', + detail: 'The tester cannot be invited because the groups they belong to have no installable builds.' }] }, 409); const u = UNIQUE[d.type]; if (u && this.all(d.type).some(r => u(r) === u(main as R))) return json({ errors: [{ status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE', title: 'The provided entity includes an attribute with a value that has already been used', detail: 'already exists' }] }, 409); @@ -267,7 +292,8 @@ export class FakeAsc { } if (!id) return json({ data: this.visible(this.filtered(this.all(type!), u.searchParams)).map(r => this.view(r)), links: {} }); const self = this.res.get(id); - if (!rel) return self && self.type === type ? json({ data: this.view(self) }) : json({ errors: [{ status: '404' }] }, 404); + // include=: the children of that type (a build's buildBundles) + if (!rel) return self && self.type === type ? json({ data: this.view(self), included: (u.searchParams.get('include') ?? '').split(',').filter(Boolean).flatMap(t => this.children(self.id, t).map(r => this.view(r))) }) : json({ errors: [{ status: '404' }] }, 404); if (rel === 'relationships') return json({ data: this.children(id, rel2!).map(r => ({ type: r.type, id: r.id })) }); // an availability's territories are the ids its create named (a to-many relationship, not child resources) if (rel === 'availableTerritories' && self) { @@ -421,6 +447,10 @@ export class FakePlay { priceLimits: Record = {}; /** product id -> 400 message for its PATCH */ rejectProducts: Record = {}; + /** live Play: committing a language that is new in the edit needs its hi-res icon, feature graphic and 2+ phone screenshots + * in the same edit (403 PERMISSION_DENIED), 2+ tablet sets too when `requireTablets` */ + requireNewLangAssets = false; + requireTablets = false; /** validate calls that carried changesNotSentForReview (live Play answers them 400 "Unknown name") */ validateWithNotSent = 0; @@ -445,12 +475,21 @@ export class FakePlay { return json({ error: { code: 400, status: 'INVALID_ARGUMENT', message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true. Once committed, the changes in this edit can be sent for review from the Google Play Console UI.', errors: [{ message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true.', domain: 'global', reason: 'badRequest' }] } }, 400); } if (method === 'POST' && rest === ':validate') return json({ id }); + if (method === 'POST' && rest === ':commit' && this.requireNewLangAssets) { + for (const lang of Object.keys(e.listings).filter(l => !this.live.listings[l])) { + const n = (t: string) => (e.images[`${lang}/${t}`] ?? []).length; + const miss = n('icon') < 1 ? `This app has no high resolution icon set for language ${lang}` : n('featureGraphic') < 1 ? `This app has no feature graphic set for language ${lang}` + : n('phoneScreenshots') < 2 || (this.requireTablets && (n('sevenInchScreenshots') < 2 || n('tenInchScreenshots') < 2)) ? `This app has too few screenshots for language ${lang}` : ''; + if (miss) return err(403, 'PERMISSION_DENIED', miss); + } + } if (method === 'POST' && rest === ':commit') { this.live = e; this.edits.delete(id!); this.committed.push(id!); return json({ id }); } if (rest === '/details') { if (method === 'PUT') e.details = body as Record; return json(e.details); } if (rest === '/listings') return json({ listings: Object.values(e.listings) }); if ((m = /^\/listings\/([^/]+)$/.exec(rest!))) { if (method === 'PUT') e.listings[m[1]!] = body as Record; return json(e.listings[m[1]!] ?? {}); } if ((m = /^\/listings\/([^/]+)\/(\w+)$/.exec(rest!))) { const k = `${m[1]}/${m[2]}`; + if (this.requireNewLangAssets && method === 'GET' && !e.listings[m[1]!]) return err(404, 'NOT_FOUND', `Listing for language '${m[1]}' not found.`); if (method === 'DELETE') { e.images[k] = []; return json({ deleted: [] }); } if (method === 'POST') { const h = new Bun.CryptoHasher('sha256').update(body as Uint8Array).digest('hex'); (e.images[k] ??= []).push(h); return json({ image: { sha256: h } }); } return json({ images: (e.images[k] ?? []).map(sha256 => ({ sha256 })) }); diff --git a/tests/helpers/preload.ts b/tests/helpers/preload.ts index 9fe5d67..35c48f4 100644 --- a/tests/helpers/preload.ts +++ b/tests/helpers/preload.ts @@ -14,6 +14,10 @@ const machine = mkdtempSync(join(tmpdir(), 'storebound-machine-')); process.env.STOREBOUND_CREDENTIALS_DIR = join(machine, 'credentials'); process.env.STOREBOUND_REGISTRY = join(machine, 'projects.json'); process.env.STOREBOUND_LOCK_DIR = join(machine, 'locks'); +// the credential broker of this machine (~/.storebound/broker.json, cli/lib/broker.ts) is never asked either +process.env.STOREBOUND_BROKER_CONFIG = join(machine, 'broker.json'); +delete process.env.STOREBOUND_BROKER_URL; +delete process.env.STOREBOUND_BROKER_KEY_FILE; // Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation: // DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code. diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts index bef1580..49f3775 100644 --- a/tests/idempotency.test.ts +++ b/tests/idempotency.test.ts @@ -370,6 +370,7 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri test('play listing: one committed edit with only what differs; the second run opens no write edit and commits nothing', async () => { s.play.live.listings['de-DE'] = { language: 'de-DE', title: 'Notizen' }; + s.play.live.listings['en-US'] = { language: 'en-US', title: 'Old' }; const p = project(CONFIG, { 'store/listing.md': LISTING }); const r = await twice(() => playListing(p.ctx([]))); expect(r.first).toEqual(['PUT /androidpublisher/v3/applications/com.example.notes/edits/edit-2/details', 'PUT /androidpublisher/v3/applications/com.example.notes/edits/edit-2/listings/en-US', @@ -380,6 +381,71 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri expect(s.play.live.listings['de-DE']).toEqual({ language: 'de-DE', title: 'Notizen' }); // a language not in the file is left alone }); + // A new Play language (tr-TR next to a live en-US): the commit is refused 403 "too few screenshots" unless the + // screenshots, icon and feature graphic travel in the same edit (pitfall 102) + const trConfig = (): ShipConfig => ({ ...CONFIG, languages: [{ id: 'en', play: 'en-US' }, { id: 'tr', play: 'tr-TR' }] as never }); + const trListing = LISTING + ['', '## Turkish', '', '', '### Play short description', '```', 'Notlar.', '```', '', '### Play full description', '```', 'Herkes icin notlar.', '```', ''].join('\n'); + const renders = (lang: string, base: number, tablets = false): Record => { + const f: Record = { [`store/renders/${lang}/feature/1024x500.png`]: png(base) }; + for (const n of [1, 2]) { f[`store/renders/${lang}/play/0${n}.png`] = png(base + n); if (tablets) for (const t of ['tab7', 'tab10']) f[`store/renders/${lang}/${t}/0${n}.png`] = png(base + n + 5); } + return f; + }; + const liveEn = () => { + s.play.live.listings['en-US'] = { language: 'en-US', title: 'Example Notes', shortDescription: 'Notes.', fullDescription: 'Notes for everyone.' }; + s.play.live.details = { defaultLanguage: 'en-US', contactEmail: 'support@example.com', contactWebsite: 'https://example.com' }; + s.play.requireNewLangAssets = true; + }; + + test('play listing: a new language uploads icon, feature graphic and phone screenshots in the same edit, so the commit passes', async () => { + liveEn(); + const p = project(trConfig(), { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1), ...renders('tr', 40) }); + const r = await capture(() => playListing(p.ctx([]))); + expect(r.exit).toBe(null); + expect(s.play.committed).toHaveLength(1); + expect(s.play.live.images['tr-TR/phoneScreenshots']).toHaveLength(2); + expect(s.play.live.images['tr-TR/icon']).toHaveLength(1); + expect(s.play.live.images['tr-TR/featureGraphic']).toHaveLength(1); + }); + + test('play listing: a new language without its own screenshots uses the default language images; none anywhere is refused in the dry run, nothing written', async () => { + liveEn(); + const base = { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1) }; + const ok = project(trConfig(), { ...base, ...renders('en', 10) }); + const r1 = await capture(() => playListing(ok.ctx([]))); + expect(r1.exit).toBe(null); + expect(r1.out).toContain("no phoneScreenshots of its own, using en-US's"); + expect(s.play.live.images['tr-TR/phoneScreenshots']).toHaveLength(2); + s.play.live.listings = { 'en-US': s.play.live.listings['en-US']! }; + s.play.live.images = {}; + const bad = project(trConfig(), base); + const before = s.play.committed.length; + const mark = s.calls.length; + await expect(playListing(bad.ctx(['--dry']))).rejects.toThrow('tr-TR: no phoneScreenshots'); + expect(s.play.committed).toHaveLength(before); + expect(writes(s.calls.slice(mark))).toEqual([]); + }); + + test('play listing: the dry run names the uploads of a new language; tablet sets are required when the default language has them on Play', async () => { + liveEn(); + s.play.requireTablets = true; + s.play.live.images['en-US/sevenInchScreenshots'] = ['x', 'y']; + const p = project(trConfig(), { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1), ...renders('tr', 40), ...renders('en', 10, true) }); + const r = await capture(() => playListing(p.ctx(['--dry']))); + expect(r.out).toContain('tr-TR is new on Play: uploads'); + expect(r.out).toContain('sevenInchScreenshots (2)'); + expect(s.play.committed).toEqual([]); + }); + + test('play images: a language with text in the listing file but no Play listing yet is created with its images, commit passes', async () => { + liveEn(); + const p = project(trConfig(), { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1), ...renders('en', 10), ...renders('tr', 40) }); + const r = await capture(() => playImages(p.ctx([]))); + expect(r.exit).toBe(null); + expect(r.out).toContain('tr-TR: no Play listing yet, this run creates it'); + expect(s.play.live.listings['tr-TR']!.title).toBeDefined(); + expect(s.play.live.images['tr-TR/phoneScreenshots']).toHaveLength(2); + }); + test('play listing: the store reached the wanted state between plan and edit: the edit is deleted, not committed', async () => { const p = project(CONFIG, { 'store/listing.md': LISTING }); // the plan sees an empty listing; right after it (before the write edit opens) another run makes it current @@ -507,6 +573,13 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri expect(s.play.committed).toHaveLength(1); }); + test('play release --dry: no upload, no track PUT (the bundle went into a discarded edit; a long upload outlived it: "This edit has expired")', async () => { + const p = project(CONFIG, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 43) }); + const r = await twice(() => playRelease(p.ctx([join(p.root, 'build/android/app.aab'), '--internal', 'completed', '--dry']))); + expect(r.first.filter(x => x.includes('/bundles') || x.startsWith('PUT '))).toEqual([]); + expect(s.play.committed).toHaveLength(0); + }); + test('prices import (Play): changed regions written once, then the diff is empty', async () => { const p = project({ ...CONFIG, products: [CONFIG.products![0]!] }, { 'store/prices.csv': 'product,store,region,currency,price\ncom.example.notes.pro,play,DE,EUR,3.49\n' }); await capture(() => playProducts(p.ctx([]))); diff --git a/tests/listing-plan.test.ts b/tests/listing-plan.test.ts index 638b555..a3acfbf 100644 --- a/tests/listing-plan.test.ts +++ b/tests/listing-plan.test.ts @@ -101,7 +101,7 @@ describe('apple listing plan', () => { const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD }); const calls = ascRoutes([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION')]); const j = jsonOut((await capture(() => appleListing(p.ctx(['--version', '1.1.0', '--dry', '--json'])))).out) as any; - expect(j.versionPlan).toMatchObject({ action: 'create', reason: 'create version 1.1.0 (no editable iOS version)' }); + expect(j.versionPlan).toMatchObject({ action: 'create', reason: expect.stringContaining('create version 1.1.0 (no editable iOS version), release type MANUAL') }); expect(j.versionLocales).toBeNull(); await capture(() => appleListing(p.ctx(['--version', '1.1.0']))); expect(methods(calls).filter(m => m.includes('appStoreVersion'))).toEqual(['POST /v1/appStoreVersions', 'POST /v1/appStoreVersionLocalizations']); diff --git a/tests/macos-overrides.test.ts b/tests/macos-overrides.test.ts new file mode 100644 index 0000000..ac9c9f2 --- /dev/null +++ b/tests/macos-overrides.test.ts @@ -0,0 +1,112 @@ +// apple.macos: per-platform listing file and review notes for an app with an iOS and a macOS App Store version, the +// config check, and the validate warning for iOS-only claims in a macOS version's texts. +import { describe, expect, test } from 'bun:test'; +import { isMacosPlatform, listingPathFor, reviewNotesFor, validateExtras, type ShipConfig } from '../cli/lib/config'; +import { reviewDetailPlan, reviewNotes } from '../cli/lib/review-details'; +import { appleRules, macosClaimRules, type AppleSnapshot } from '../cli/lib/validate-rules'; + +const cfg = (apple: Record) => ({ apple: { bundleId: 'com.example.app', ...apple } }) as unknown as Pick; +const contact = { contactFirstName: 'A', contactLastName: 'B', contactPhone: '+10000000000', contactEmail: 'a@example.com' }; + +describe('platform helpers', () => { + test('spellings of macOS', () => { + for (const p of ['macos', 'MAC_OS', 'macOS', 'mac']) expect(isMacosPlatform(p)).toBe(true); + for (const p of ['ios', 'IOS', 'tvos', '', undefined]) expect(isMacosPlatform(p)).toBe(false); + }); + test('listingPathFor: macOS override, else apple.listing, else the default', () => { + const c = cfg({ listing: 'store/ios.md', macos: { listing: 'store/mac.md' } }); + expect(listingPathFor(c, 'macos')).toBe('store/mac.md'); + expect(listingPathFor(c, 'MAC_OS')).toBe('store/mac.md'); + expect(listingPathFor(c, 'ios')).toBe('store/ios.md'); + expect(listingPathFor(c)).toBe('store/ios.md'); + expect(listingPathFor(cfg({ macos: {} }), 'macos')).toBe('store/appstore.md'); + expect(listingPathFor(cfg({ listing: 'store/ios.md', macos: { reviewNotes: 'x' } }), 'macos')).toBe('store/ios.md'); + }); + test('reviewNotesFor: macOS override, else apple.info.reviewNotes', () => { + const c = cfg({ info: { reviewNotes: 'iOS notes' }, macos: { reviewNotes: 'Mac notes' } }); + expect(reviewNotesFor(c, 'macos')).toBe('Mac notes'); + expect(reviewNotesFor(c, 'ios')).toBe('iOS notes'); + expect(reviewNotesFor(cfg({ info: { reviewNotes: 'iOS notes' }, macos: {} }), 'macos')).toBe('iOS notes'); + }); +}); + +describe('review details plan', () => { + const c = { ...cfg({ info: { reviewNotes: 'iOS notes: Game Center, IAP' }, macos: { reviewNotes: 'Mac notes: no purchases' } }), reviewAccess: { signInRequired: false, instructions: 'No sign-in.' } } as Pick; + test('macos picks the Mac notes, ios and default keep the iOS notes; the shared sign-in line stays', () => { + expect(reviewDetailPlan(c, contact, null, 'macos').attributes.notes).toBe('Mac notes: no purchases\n\nSign-in: No sign-in.'); + expect(reviewDetailPlan(c, contact, null, 'ios').attributes.notes).toBe('iOS notes: Game Center, IAP\n\nSign-in: No sign-in.'); + expect(reviewDetailPlan(c, contact, null).attributes.notes).toBe(reviewNotes(c)); + }); +}); + +describe('config check', () => { + test('apple.macos shape', () => { + expect(validateExtras({ apple: { bundleId: 'com.example.app', macos: { listing: 'a.md', reviewNotes: 'x', gameCenter: true, noIap: true } } } as never)).toEqual([]); + const bad = validateExtras({ apple: { bundleId: 'com.example.app', macos: { listing: '', gameCenter: 'yes', noIap: 1 } } } as never); + expect(bad.some(m => m.startsWith('apple.macos.listing'))).toBe(true); + expect(bad.some(m => m.startsWith('apple.macos.gameCenter'))).toBe(true); + expect(bad.some(m => m.startsWith('apple.macos.noIap'))).toBe(true); + expect(validateExtras({ apple: { bundleId: 'com.example.app', macos: 'x' } } as never).some(m => m.startsWith('apple.macos:'))).toBe(true); + }); +}); + +describe('validate: iOS-only claims on a macOS version', () => { + const loc = (o: Record) => [{ locale: 'en-US', screenshots: {}, ...o }]; + const snap = (over: Partial): Pick => + ({ version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION', platform: 'MAC_OS' }, locales: [], reviewDetail: null, ...over }); + const codes = (f: { msg: string }[]) => f.map(x => x.msg); + + test('Game Center, touch words and iPhone mentions warn; nothing blocks', () => { + const f = macosClaimRules(snap({ locales: loc({ description: 'Compete on Game Center. Tap and swipe to play on your iPhone.' }) })); + expect(f.length).toBe(3); + expect(f.every(x => x.level === 'warn' && x.code === 'macos-claims')).toBe(true); + expect(codes(f).join('\n')).toContain('en-US description'); + }); + test('promotional text, what\'s new, keywords and live review notes are read', () => { + const f = macosClaimRules(snap({ locales: loc({ promotionalText: 'Use the touch screen', whatsNew: 'Pinch to zoom', keywords: 'ipad' }), reviewDetail: { notes: 'Sign in with Game Center' } })); + const all = codes(f).join('\n'); + expect(all).toContain('en-US promotionalText'); + expect(all).toContain('en-US whatsNew'); + expect(all).toContain('en-US keywords'); + expect(all).toContain('review notes (on the store)'); + }); + test('the config review notes the Mac would use are checked too', () => { + expect(codes(macosClaimRules(snap({ macos: { notes: 'Game Center leaderboards are in the menu' } }))).join()).toContain('review notes (config)'); + }); + test('apple.macos.gameCenter silences Game Center; IAP lines only with noIap', () => { + const text = loc({ description: 'Game Center scores. Remove ads with an in-app purchase.' }); + expect(macosClaimRules(snap({ locales: text, macos: { gameCenter: true } }))).toEqual([]); + const f = macosClaimRules(snap({ locales: text, macos: { gameCenter: true, noIap: true } })); + expect(f.length).toBe(1); + expect(f[0]!.msg).toContain('in-app purchase'); + }); + test('words that only look similar do not match; iOS versions are never checked', () => { + expect(macosClaimRules(snap({ locales: loc({ description: 'Contact the support team; a stapler, a tapestry, an iPhoneless desk.' }) }))).toEqual([]); + expect(macosClaimRules(snap({ version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, locales: loc({ description: 'Game Center, tap' }) }))).toEqual([]); + }); + test('appleRules includes the warning', () => { + const base = { expectedLocales: [], requiredDisplayTypes: [], iaps: [], expectedProducts: [], ageRating: null, priceSchedule: false, availability: false, build: null, privacyUrl: { url: 'https://example.com', status: 200 } }; + const f = appleRules({ ...base, ...snap({ locales: loc({ description: 'Tap here', screenshots: {} as never }) }) } as AppleSnapshot); + expect(f.some(x => x.code === 'macos-claims' && x.level === 'warn')).toBe(true); + }); +}); + +describe('apple listing reads the platform file', () => { + test('readAppleCopy(ctx, "macos") reads apple.macos.listing, ios reads apple.listing', async () => { + const { mkdtempSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join, resolve } = await import('node:path'); + const { readAppleCopy } = await import('../cli/apple/listing'); + const { parseArgs } = await import('../cli/lib/args'); + const fence = '`'.repeat(3); + const sec = (d: string) => ['## en-US', '', '### subtitle', fence, 'Sub', fence, '', '### promotionalText', fence, 'Promo', fence, '', '### keywords', fence, 'run,time', fence, '', '### description', fence, d, fence, ''].join('\n'); + const dir = mkdtempSync(join(tmpdir(), 'sb-macos-')); + writeFileSync(join(dir, 'ios.md'), sec('Play on iPhone with Game Center.')); + writeFileSync(join(dir, 'mac.md'), sec('Play on your Mac.')); + const loaded = { config: cfg({ listing: 'ios.md', macos: { listing: 'mac.md' } }), resolve: (p: string) => resolve(dir, p), root: dir }; + const ctx = { loaded, args: parseArgs([]) } as never; + expect(readAppleCopy(ctx, 'macos')['en-US']?.description).toContain('Mac'); + expect(readAppleCopy(ctx, 'ios')['en-US']?.description).toContain('iPhone'); + expect(readAppleCopy(ctx)['en-US']?.description).toContain('iPhone'); + }); +}); diff --git a/tests/mas-entitlements.test.ts b/tests/mas-entitlements.test.ts new file mode 100644 index 0000000..3de48d6 --- /dev/null +++ b/tests/mas-entitlements.test.ts @@ -0,0 +1,191 @@ +// Mac App Store entitlements of a Tauri build (cli/lib/mas.ts, cli/apple/build-mas.ts): the overlay config +// (tauri..conf.json) merged over tauri.conf.json, the source order of the entitlements plist, and the check of +// Developer ID / development keys that do not belong in a Mac App Store build. +import { afterAll, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { run as buildMas } from '../cli/apple/build-mas'; +import type { ShipConfig } from '../cli/lib/config'; +import { appProblems, entitlementsSource, entitlementsXml, findTauri, masEntitlementProblems, masSteps, mergePatch, tauriOverlay } from '../cli/lib/mas'; +import { BASE, capture, project } from './helpers/store-mock'; + +const tmp = mkdtempSync(join(tmpdir(), 'sb-masent-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const BASE_CONF = { productName: 'Demo App', identifier: 'com.example.notes', version: '1.2.3', bundle: { category: 'Utility', macOS: { entitlements: 'Entitlements.plist', minimumSystemVersion: '12.0' } } }; +const OVERLAY = { bundle: { macOS: { entitlements: 'Entitlements.appstore.plist' } } }; +const DEVID = { 'com.apple.security.cs.disable-library-validation': true, 'com.apple.security.cs.allow-jit': true, 'com.apple.security.network.client': true }; +const APPSTORE = { 'com.apple.security.files.user-selected.read-only': true, 'com.apple.security.network.client': true }; + +function fixture(files: Record = {}): { root: string; dir: string } { + const root = mkdtempSync(join(tmp, 'p-')); + const dir = join(root, 'src-tauri'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'tauri.conf.json'), JSON.stringify(BASE_CONF)); + for (const [n, v] of Object.entries(files)) writeFileSync(join(dir, n), typeof v === 'string' ? v : JSON.stringify(v)); + return { root, dir }; +} + +describe('merge patch', () => { + test('objects merge, null removes, arrays and scalars replace', () => { + expect(mergePatch({ a: { b: 1, c: 2 }, d: [1, 2], e: 1 }, { a: { b: 9 }, d: [3], e: null })).toEqual({ a: { b: 9, c: 2 }, d: [3] }); + expect(mergePatch({ a: 1 }, { a: { x: 1 } })).toEqual({ a: { x: 1 } }); + expect(mergePatch('x', 5)).toBe(5); + }); +}); + +describe('overlay config', () => { + test('an overlay file path is merged over the base config in its folder', () => { + const { root, dir } = fixture({ 'tauri.appstore.conf.json': OVERLAY }); + const t = findTauri(root, 'src-tauri/tauri.appstore.conf.json'); + expect(t.overlayPath).toBe(join(dir, 'tauri.appstore.conf.json')); + expect(t.overlayAuto).toBe(false); + expect(t.entitlements).toBe(join(dir, 'Entitlements.appstore.plist')); + expect(t.productName).toBe('Demo App'); + expect(t.minOs).toBe('12.0'); + }); + test('a folder picks up tauri.appstore.conf.json, then tauri.mas.conf.json, on its own; a scan does too', () => { + const a = fixture({ 'tauri.appstore.conf.json': OVERLAY }); + expect(findTauri(a.root, 'src-tauri')).toMatchObject({ overlayPath: join(a.dir, 'tauri.appstore.conf.json'), overlayAuto: true }); + expect(findTauri(a.root).overlayAuto).toBe(true); + const m = fixture({ 'tauri.mas.conf.json': OVERLAY }); + expect(findTauri(m.root, 'src-tauri').overlayPath).toBe(join(m.dir, 'tauri.mas.conf.json')); + const both = fixture({ 'tauri.appstore.conf.json': OVERLAY, 'tauri.mas.conf.json': { bundle: { macOS: { entitlements: 'other.plist' } } } }); + expect(findTauri(both.root, 'src-tauri').entitlements).toBe(join(both.dir, 'Entitlements.appstore.plist')); + }); + test('naming the base file directly still auto-detects; no overlay means none', () => { + const { root, dir } = fixture({ 'tauri.appstore.conf.json': OVERLAY }); + expect(findTauri(root, 'src-tauri/tauri.conf.json').entitlements).toBe(join(dir, 'Entitlements.appstore.plist')); + const plain = fixture(); + const t = findTauri(plain.root, 'src-tauri'); + expect(t.overlayPath).toBeUndefined(); + expect(t.entitlements).toBe(join(plain.dir, 'Entitlements.plist')); + }); + test('an overlay without a base config, a missing file, bad JSON', () => { + const root = mkdtempSync(join(tmp, 'o-')); + mkdirSync(join(root, 'x'), { recursive: true }); + writeFileSync(join(root, 'x/tauri.appstore.conf.json'), '{}'); + expect(() => findTauri(root, 'x/tauri.appstore.conf.json')).toThrow('no tauri.conf.json next to this overlay'); + const { root: r2 } = fixture(); + expect(() => findTauri(r2, 'src-tauri/tauri.nope.conf.json')).toThrow('no such file'); + const bad = fixture({ 'tauri.appstore.conf.json': '{ nope' }); + expect(() => findTauri(bad.root, 'src-tauri')).toThrow('not valid JSON'); + }); + test('tauri build gets the overlay first and storebound\'s own overlay last', () => { + const { root, dir } = fixture({ 'tauri.appstore.conf.json': OVERLAY }); + const tauri = findTauri(root, 'src-tauri'); + const input = { tauri, tauriCmd: ['tauri'], target: 'universal-apple-darwin', out: join(root, 'out'), configFile: join(root, 'out/tauri.mas.conf.json'), entitlementsFile: 'e', profile: null, + appIdentity: null, installerIdentity: null, version: '1.0.0', build: '1', unsigned: true, pathFree: false, pkg: 'x.pkg' }; + const argv = masSteps(input).tauri; + const cfgs = argv.flatMap((x, i) => (x === '--config' ? [argv[i + 1]] : [])); + expect(cfgs).toEqual([join(dir, 'tauri.appstore.conf.json'), input.configFile]); + const plain = masSteps({ ...input, tauri: findTauri(fixture().root, 'src-tauri') }).tauri; + expect(plain.filter(x => x === '--config').length).toBe(1); + expect(tauriOverlay({ appIdentity: 'id', entitlementsFile: 'e', profile: null, version: '1', build: '1', unsigned: false })).toBeTruthy(); + }); +}); + +describe('entitlements source order', () => { + test('flag, then config, then the Tauri config', () => { + expect(entitlementsSource({ flag: '/f', config: '/c', tauri: '/t' })).toEqual({ file: '/f', source: '--entitlements' }); + expect(entitlementsSource({ config: '/c', tauri: '/t' })).toEqual({ file: '/c', source: 'apple.macos.entitlements' }); + expect(entitlementsSource({ tauri: '/t' })).toEqual({ file: '/t', source: 'tauri config bundle.macOS.entitlements' }); + expect(entitlementsSource({})).toBeNull(); + }); +}); + +describe('entitlement key check', () => { + test('refused keys are errors, hardened-runtime exceptions warnings, false values and fine keys pass', () => { + const r = masEntitlementProblems({ + ...DEVID, 'com.apple.security.get-task-allow': true, 'com.apple.security.cs.allow-unsigned-executable-memory': true, + 'com.apple.security.cs.disable-executable-page-protection': true, 'com.apple.security.cs.allow-dyld-environment-variables': true, + 'com.apple.security.automation.apple-events': true, 'com.apple.security.cs.debugger': false, 'com.apple.security.files.user-selected.read-only': true, + }); + expect(r.errors.map(e => e.split(' ')[0])).toEqual(['com.apple.security.cs.disable-library-validation', 'com.apple.security.get-task-allow']); + expect(r.warnings.map(e => e.split(' ')[0]).sort()).toEqual([ + 'com.apple.security.automation.apple-events', 'com.apple.security.cs.allow-dyld-environment-variables', 'com.apple.security.cs.allow-jit', + 'com.apple.security.cs.allow-unsigned-executable-memory', 'com.apple.security.cs.disable-executable-page-protection', + ]); + expect(masEntitlementProblems(APPSTORE)).toEqual({ errors: [], warnings: [] }); + expect(masEntitlementProblems(null)).toEqual({ errors: [], warnings: [] }); + }); + test('the built app is checked too; --allow-entitlements turns the errors into warnings', () => { + const facts = { + info: { CFBundleIdentifier: 'com.example.notes', CFBundleShortVersionString: '1.0.0', CFBundleVersion: '5', LSApplicationCategoryType: 'public.app-category.utilities', LSMinimumSystemVersion: '12.0', ITSAppUsesNonExemptEncryption: false }, + entitlements: { 'com.apple.security.app-sandbox': true, 'com.apple.application-identifier': 'T.com.example.notes', 'com.apple.developer.team-identifier': 'T', ...DEVID }, + hasProfile: true, leaks: [], + }; + const o = { bundleId: 'com.example.notes', version: '1.0.0', build: '5', signed: true, pathFree: false }; + const r = appProblems(facts, o); + expect(r.errors.join()).toContain('disable-library-validation'); + expect(r.warnings.join()).toContain('allow-jit'); + const allowed = appProblems(facts, { ...o, allowEntitlements: true }); + expect(allowed.errors).toEqual([]); + expect(allowed.warnings.join()).toContain('disable-library-validation'); + }); +}); + +// the whole command, as a dry run (no store, no Mac) +describe('apple build --platform macos --dry', () => { + const CONFIG: ShipConfig = { ...BASE }; + const files = (extra: Record = {}): Record => ({ + 'src-tauri/tauri.conf.json': JSON.stringify(BASE_CONF), + 'src-tauri/tauri.appstore.conf.json': JSON.stringify(OVERLAY), + 'src-tauri/Entitlements.plist': entitlementsXml(DEVID), + 'src-tauri/Entitlements.appstore.plist': entitlementsXml(APPSTORE), + ...extra, + }); + const dry = async (cfg: ShipConfig, f: Record, argv: string[]): Promise<{ plan: any; err: string }> => { + const p = project(cfg, f); + const r = await capture(() => buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry', '--json', ...argv]), 'linux')); + return { plan: JSON.parse(r.out.slice(r.out.indexOf('{'))), err: r.err }; + }; + test('the App Store overlay is found and its plist used, not the Developer ID one', async () => { + const { plan } = await dry(CONFIG, files(), []); + expect(String(plan.tauriOverlayFile).replaceAll("\\", "/")).toBe('src-tauri/tauri.appstore.conf.json'); + expect({ ...plan.entitlementsSource, file: plan.entitlementsSource.file.replaceAll("\\", "/") }).toEqual({ source: 'tauri config bundle.macOS.entitlements', file: 'src-tauri/Entitlements.appstore.plist' }); + expect(plan.entitlements).toContain('com.apple.security.files.user-selected.read-only'); + expect(plan.entitlements).not.toContain('com.apple.security.cs.disable-library-validation'); + expect(plan.entitlementProblems).toEqual({ errors: [], warnings: [] }); + expect(plan.steps[0]).toContain('tauri.appstore.conf.json'); + }); + test('the Developer ID plist is reported (error under a real build, a warning here)', async () => { + const f = files(); delete (f as Record)['src-tauri/tauri.appstore.conf.json']; + const { plan, err } = await dry(CONFIG, f, []); + expect(plan.entitlementsSource.file.replaceAll("\\", "/")).toBe('src-tauri/Entitlements.plist'); + expect(plan.entitlementProblems.errors.join()).toContain('disable-library-validation'); + expect(plan.entitlementProblems.warnings.join()).toContain('allow-jit'); + expect(err).toBe(''); // --json prints only the plan + const pj = project(CONFIG, f); + const text = await capture(() => buildMas(pj.ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry']), 'linux')); + expect(text.err).toContain('disable-library-validation'); + expect(text.err).toContain('--allow-entitlements'); + expect(text.out).toContain('entitlements from tauri config'); + }); + test('--entitlements beats apple.macos.entitlements beats the Tauri config', async () => { + const f = files({ 'a.plist': entitlementsXml({ 'com.apple.security.files.downloads.read-only': true }), 'b.plist': entitlementsXml({ 'com.apple.security.files.user-selected.read-write': true }) }); + delete (f as Record)['src-tauri/tauri.appstore.conf.json']; + const cfg: ShipConfig = { ...CONFIG, apple: { ...CONFIG.apple!, macos: { entitlements: 'a.plist' } } }; + const viaConfig = await dry(cfg, f, []); + expect(viaConfig.plan.entitlementsSource).toEqual({ source: 'apple.macos.entitlements', file: 'a.plist' }); + expect(viaConfig.plan.entitlements).toContain('com.apple.security.files.downloads.read-only'); + const viaFlag = await dry(cfg, f, ['--entitlements', 'b.plist']); + expect(viaFlag.plan.entitlementsSource).toEqual({ source: '--entitlements', file: 'b.plist' }); + expect(viaFlag.plan.entitlements).toContain('com.apple.security.files.user-selected.read-write'); + expect(viaFlag.plan.entitlements).not.toContain('com.apple.security.files.downloads.read-only'); + }); + test('only apple.macos.prebuild runs before the Mac build; the iOS project\'s build.prebuild is named, not run', async () => { + const mobile: ShipConfig = { ...CONFIG, build: { prebuild: 'npx cap sync' } }; + expect((await dry(mobile, files(), [])).plan.prebuild).toBeNull(); + const text = await capture(() => buildMas(project(mobile, files()).ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry']), 'linux')); + expect(text.out).toContain('not run for the Mac build: npx cap sync'); + const mac: ShipConfig = { ...mobile, apple: { ...CONFIG.apple!, macos: { prebuild: 'bun run desktop:env' } } }; + expect((await dry(mac, files(), [])).plan.prebuild).toBe('bun run desktop:env'); + expect((await dry(mac, files(), ['--no-prebuild'])).plan.prebuildSkip).toBe('--no-prebuild'); + }); + test('a named plist that does not exist stops the run', async () => { + const p = project(CONFIG, files()); + await expect(buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry', '--entitlements', 'nope.plist']), 'linux')).rejects.toThrow('does not exist'); + }); +}); diff --git a/tests/mcp-http.test.ts b/tests/mcp-http.test.ts new file mode 100644 index 0000000..2fc8ed0 --- /dev/null +++ b/tests/mcp-http.test.ts @@ -0,0 +1,161 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { answer, insideRoots, introspectionOk, metadataUrl, originAllowed, projectFor, startHttp, tokenFrom } from '../cli/mcp-http'; + +const root = mkdtempSync(join(tmpdir(), 'mcph-')); +const app = mkdtempSync(join(root, 'app-')); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +describe('pure parts', () => { + test('roots and project', () => { + expect(insideRoots(app, [root])).toBe(true); + expect(insideRoots(join(root, '..'), [root])).toBe(false); + expect(projectFor(undefined, { roots: [root], project: null })).toEqual({ error: 'project: required (absolute path of the app folder)' }); + expect(projectFor(undefined, { roots: [root], project: app })).toEqual({ dir: app }); + expect(projectFor('rel/dir', { roots: [root], project: null })).toEqual({ error: 'project: must be an absolute path, or a folder name under the roots' }); + expect(projectFor(basename(app), { roots: [root], project: null })).toEqual({ dir: app }); + expect('error' in projectFor('nope', { roots: [root], project: null })).toBe(true); + expect(projectFor(app.split(/[\/]/).pop(), { roots: [root], project: null })).toEqual({ dir: app }); + expect('error' in projectFor('nope', { roots: [root], project: null })).toBe(true); + expect('error' in projectFor(tmpdir(), { roots: [root], project: null })).toBe(true); + expect('error' in projectFor(join(root, 'missing'), { roots: [root], project: null })).toBe(true); + expect(projectFor(app, { roots: [root], project: null })).toEqual({ dir: app }); + }); + test('origin, metadata url, introspection', () => { + expect(originAllowed(undefined, [])).toBe(true); + expect(originAllowed('http://localhost:3000', [])).toBe(true); + expect(originAllowed('https://evil.example.com', [])).toBe(false); + expect(originAllowed('https://app.example.com', ['https://app.example.com'])).toBe(true); + expect(metadataUrl('https://sb.example.com/mcp')).toBe('https://sb.example.com/.well-known/oauth-protected-resource/mcp'); + const r = 'https://sb.example.com/mcp'; + expect(introspectionOk({ active: true, scope: 'mcp apps:read', aud: [r] }, r)).toBe(true); + expect(introspectionOk({ active: true, scope: 'apps:read', aud: [r] }, r)).toBe(false); + expect(introspectionOk({ active: true, scope: 'mcp', aud: ['https://other.example.com/mcp'] }, r)).toBe(false); + expect(introspectionOk({ active: false }, r)).toBe(false); + }); + test('token file is made once, mode 600 off Windows', () => { + const f = join(root, 'tok', 'mcp-token'); + const t = tokenFrom(f); + expect(t).toMatch(/^[0-9a-f]{64}$/); + expect(tokenFrom(f)).toBe(t); + if (process.platform !== 'win32') expect(statSync(f).mode & 0o777).toBe(0o600); + }); + test('tools/list gains a required project; tools/call runs in it and drops it from the arguments', async () => { + const list = await answer({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, { roots: [root], project: null }); + const check = ((list!.result as { tools: { name: string; inputSchema: { properties: Record; required?: string[] } }[] }).tools).find(t => t.name === 'check')!; + expect(check.inputSchema.properties.project).toBeDefined(); + expect(check.inputSchema.required).toContain('project'); + let seen = null as { argv: string[]; cwd: string } | null; + const r = await answer({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'check', arguments: { project: app } } }, { roots: [root], project: null }, + async (argv, cwd) => { seen = { argv, cwd }; return { code: 0, out: 'ok' }; }); + expect(seen).toEqual({ argv: ['check'], cwd: app }); + expect((r!.result as { isError: boolean }).isError).toBe(false); + const bad = await answer({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'check', arguments: {} } }, { roots: [root], project: null }, async () => { throw new Error('must not run'); }); + expect((bad!.result as { isError: boolean }).isError).toBe(true); + }); +}); + +describe('http server', () => { + const post = (port: number, body: unknown, headers: Record = {}) => + fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...headers }, body: JSON.stringify(body) }); + + test('token auth: 401 without, initialize with, 202 for a notification, 405 for GET, 403 for a foreign origin', async () => { + const s = await startHttp({ host: '127.0.0.1', port: 0, roots: [root], project: null, allowOrigins: [], auth: { kind: 'token', token: 'a'.repeat(64) } }); + try { + const auth = { authorization: `Bearer ${'a'.repeat(64)}` }; + expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' })).status).toBe(401); + expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { authorization: 'Bearer nope' })).status).toBe(401); + const init = await post(s.port, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '1' } } }, auth); + expect(init.status).toBe(200); + expect(((await init.json()) as { result: { protocolVersion: string } }).result.protocolVersion).toBe('2025-06-18'); + expect((await post(s.port, { jsonrpc: '2.0', method: 'notifications/initialized' }, auth)).status).toBe(202); + expect((await fetch(`http://127.0.0.1:${s.port}/mcp`, { headers: auth })).status).toBe(405); + expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { ...auth, origin: 'https://evil.example.com' })).status).toBe(403); + expect((await post(s.port, [{ jsonrpc: '2.0', id: 1, method: 'ping' }], auth)).status).toBe(400); + } finally { s.close(); } + }); + + test('introspect auth: metadata document, 401 names it, a valid token passes, a token without mcp scope does not', async () => { + const resource = 'https://sb.example.com/mcp'; + let calls = 0; + const idp = createServer((req, res) => { + calls++; + let b = ''; req.on('data', c => (b += c)); req.on('end', () => { + const tok = new URLSearchParams(b).get('token'); + const ok = req.headers.authorization === 'Bearer server-key'; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(!ok ? { active: false } : tok === 'good' ? { active: true, scope: 'mcp', aud: [resource] } : tok === 'noscope' ? { active: true, scope: 'apps', aud: [resource] } : { active: false })); + }); + }); + await new Promise(r => idp.listen(0, '127.0.0.1', () => r())); + const ip = (idp.address() as { port: number }).port; + const s = await startHttp({ host: '127.0.0.1', port: 0, roots: [root], project: app, allowOrigins: [], + auth: { kind: 'introspect', url: `http://127.0.0.1:${ip}/introspect`, key: 'server-key', resource, issuer: 'https://idp.example.com' } }); + try { + const meta = await fetch(`http://127.0.0.1:${s.port}/.well-known/oauth-protected-resource/mcp`); + expect(await meta.json()).toMatchObject({ resource, authorization_servers: ['https://idp.example.com'], scopes_supported: ['mcp'] }); + const no = await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }); + expect(no.status).toBe(401); + expect(no.headers.get('www-authenticate')).toBe('Bearer resource_metadata="https://sb.example.com/.well-known/oauth-protected-resource/mcp"'); + expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { authorization: 'Bearer good' })).status).toBe(200); + expect((await post(s.port, { jsonrpc: '2.0', id: 2, method: 'ping' }, { authorization: 'Bearer good' })).status).toBe(200); + expect(calls).toBe(1); // cached + expect((await post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { authorization: 'Bearer noscope' })).status).toBe(401); + } finally { s.close(); idp.close(); } + }); +}); + +describe('read-only and broker profiles', () => { + test('read-only lists only read tools and refuses a write tool', async () => { + const list = await answer({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, { roots: [root], project: app, readOnly: true }); + const tools = (list!.result as { tools: { name: string; annotations: { readOnlyHint: boolean } }[] }).tools; + expect(tools.length).toBeGreaterThan(5); + expect(tools.every(t => t.annotations.readOnlyHint)).toBe(true); + const w = await answer({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'play_listing', arguments: {} } }, { roots: [root], project: app, readOnly: true }, async () => { throw new Error('must not run'); }); + expect((w as { error: { message: string } }).error.message).toContain('read-only'); + }); + test('broker store credentials become STOREBOUND_PROFILE__* env', async () => { + const { brokerProfileEnv } = await import('../cli/lib/broker'); + const srv = createServer((req, res) => { + res.setHeader('content-type', 'application/json'); + const m = /^\/api\/store-credentials\/([^/]+)\/([^/]+)$/.exec(req.url ?? ''); + if (req.headers.authorization !== 'Bearer kkkkkkkk') { res.statusCode = 401; return res.end('{}'); } + if (m?.[1] === 'teamA' && m[2] === 'asc-api-key') return res.end(JSON.stringify({ secret: '-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----', metadata: { issuerId: 'iss', keyId: 'KEY' } })); + if (m?.[1] === 'teamA' && m[2] === 'play-service-account') return res.end(JSON.stringify({ secret: '{"client_email":"a@example.com"}', metadata: {} })); + res.statusCode = 404; res.end('{"error":"err_not_found"}'); + }); + await new Promise(r => srv.listen(0, '127.0.0.1', () => r())); + const port = (srv.address() as { port: number }).port; + const keyFile = join(root, 'broker.key'); + (await import('node:fs')).writeFileSync(keyFile, 'kkkkkkkk', { mode: 0o600 }); + try { + const env = await brokerProfileEnv({ url: `http://127.0.0.1:${port}`, keyFile, account: null, from: 'test' }, ['teamA', 'other']); + expect(Object.keys(env).sort()).toEqual(['STOREBOUND_PROFILE_TEAMA_ASC_ISSUER_ID', 'STOREBOUND_PROFILE_TEAMA_ASC_KEY_ID', 'STOREBOUND_PROFILE_TEAMA_ASC_P8', 'STOREBOUND_PROFILE_TEAMA_PLAY_SERVICE_ACCOUNT']); + expect(env.STOREBOUND_PROFILE_TEAMA_ASC_KEY_ID).toBe('KEY'); + } finally { srv.close(); } + }); +}); + +describe('long calls over SSE', () => { + test('a tools/call that accepts text/event-stream gets keepalive comments, then the answer as one event', async () => { + const { setKeepaliveMs } = await import('../cli/mcp-http'); + setKeepaliveMs(50); + const s = await startHttp({ host: '127.0.0.1', port: 0, roots: [root], project: app, allowOrigins: [], auth: { kind: 'token', token: 'b'.repeat(64) } }); + try { + const r = await fetch(`http://127.0.0.1:${s.port}/mcp`, { method: 'POST', + headers: { authorization: `Bearer ${'b'.repeat(64)}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'check', arguments: { project: '/nope' } } }) }); + expect(r.headers.get('content-type')).toBe('text/event-stream'); + const text = await r.text(); + expect(text.startsWith(': started')).toBe(true); + const data = /event: message\ndata: (.*)\n\n$/.exec(text)!; + expect(JSON.parse(data[1]!)).toMatchObject({ id: 9, result: { isError: true } }); + // JSON for everything else + const p = await fetch(`http://127.0.0.1:${s.port}/mcp`, { method: 'POST', headers: { authorization: `Bearer ${'b'.repeat(64)}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) }); + expect(p.headers.get('content-type')).toBe('application/json'); + } finally { s.close(); setKeepaliveMs(20_000); } + }); +}); diff --git a/tests/mcp-public.test.ts b/tests/mcp-public.test.ts new file mode 100644 index 0000000..aab684e --- /dev/null +++ b/tests/mcp-public.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { handle } from '../cli/mcp'; +import { PUBLIC_MAX_BODY, startHttp } from '../cli/mcp-http'; +import { clientKey, createLimiter, publicSurface, searchSections, sectionsOf, validatePublic } from '../cli/mcp-public'; + +const noRun = async (): Promise<{ code: number; out: string }> => { throw new Error('a child process must never start in public mode'); }; + +async function fakeItunes(): Promise<{ url: string; close: () => void; queries: string[] }> { + const queries: string[] = []; + const srv = createServer((req, res) => { + queries.push(req.url ?? ''); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ resultCount: 2, results: [ + { trackId: 111, trackName: 'Example One', sellerName: 'Example Studio', averageUserRating: 4.56789, userRatingCount: 1234, primaryGenreName: 'Games', bundleId: 'com.example.one', description: 'x'.repeat(5000) }, + { trackId: 222, trackName: 'Example Two', sellerName: 'Other Studio', primaryGenreName: 'Productivity' }, + ] })); + }); + await new Promise(r => srv.listen(0, '127.0.0.1', () => r())); + return { url: `http://127.0.0.1:${(srv.address() as { port: number }).port}/search`, close: () => srv.close(), queries }; +} + +type Reply = { error?: { message: string }; result?: { isError: boolean; content: { text: string }[]; structuredContent?: { result: any } } }; +/** One tools/call through the real handler, as the HTTP server does it. */ +async function call(surface: ReturnType, name: string, args: unknown): Promise { + return (await handle({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args as Record } }, noRun, surface)) as Reply; +} +const text = (r: Reply) => r.result!.content[0]!.text; + +describe('tool list', () => { + const surface = publicSurface(); + test('only the public tools, all read-only, closed schemas', async () => { + const r = (await handle({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, noRun, surface))!.result as { tools: any[] }; + expect(r.tools.map(t => t.name).sort()).toEqual(['aso_search', 'listing_lint', 'pitfalls_search', 'skill_get', 'skills_list', 'store_limits']); + for (const t of r.tools) { + expect(t.inputSchema.additionalProperties).toBe(false); + expect(t.annotations.readOnlyHint).toBe(true); + expect(t.annotations.destructiveHint).toBe(false); + } + expect(r.tools.find(t => t.name === 'aso_search').annotations.openWorldHint).toBe(true); + expect(r.tools.find(t => t.name === 'store_limits').annotations.openWorldHint).toBe(false); + }); + test('initialize and server/discover say it is the public key-less server', async () => { + const init = (await handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } }, noRun, surface))!.result as { instructions: string }; + expect(init.instructions).toContain('public, key-less'); + expect(init.instructions).toContain('npx storebound'); + const meta = { 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientCapabilities': {} }; + const d = (await handle({ jsonrpc: '2.0', id: 2, method: 'server/discover', params: { _meta: meta } }, noRun, surface))!.result as { instructions: string }; + expect(d.instructions).toContain('public, key-less'); + }); + test('CLI tools are not reachable', async () => { + for (const name of ['check', 'play_listing', 'owner_step', 'validate', 'api_get']) { + const r = await call(surface, name, {}); + expect(r.error?.message).toContain('Unknown tool'); + } + }); +}); + +describe('skills', () => { + const surface = publicSurface(); + test('skills_list names every skill of the package', async () => { + const r = await call(surface, 'skills_list', {}); + const skills = r.result!.structuredContent!.result.skills as { name: string; description: string }[]; + expect(skills.length).toBeGreaterThan(20); + expect(skills.find(s => s.name === 'store-pitfalls')!.description.length).toBeGreaterThan(20); + expect(skills.every(s => /^[a-z0-9-]+$/.test(s.name) && !s.description.includes('\n'))).toBe(true); + }); + test('skill_get returns the file text; bad names are refused', async () => { + const r = await call(surface, 'skill_get', { name: 'aso-basics' }); + expect(r.result!.isError).toBe(false); + expect(text(r)).toContain('name: aso-basics'); + for (const bad of ['../package', 'ASO-basics', 'no-such-skill', 'a/b', '']) expect((await call(surface, 'skill_get', { name: bad })).result!.isError).toBe(true); + expect((await call(surface, 'skill_get', {})).result!.isError).toBe(true); + expect((await call(surface, 'skill_get', { name: 'aso-basics', extra: 1 })).result!.isError).toBe(true); + }); +}); + +describe('pitfalls_search', () => { + const surface = publicSurface(); + test('finds sections, best first, at most 8', async () => { + const r = await call(surface, 'pitfalls_search', { query: 'keyword field length' }); + expect(r.result!.isError).toBe(false); + expect(text(r)).toMatch(/^## /); + expect(text(r).toLowerCase()).toContain('keyword'); + const m = r.result!.structuredContent!.result.matches as { score: number }[]; + expect(m.length).toBeLessThanOrEqual(8); + expect(m.map(x => x.score)).toEqual([...m.map(x => x.score)].sort((a, b) => b - a)); + expect(text(r).length).toBeLessThan(40_100); + }); + test('store filter, no match, bad input', async () => { + expect((await call(surface, 'pitfalls_search', { query: 'screenshots', store: 'play' })).result!.isError).toBe(false); + expect(text(await call(surface, 'pitfalls_search', { query: 'zzqqxxnotaword' }))).toContain('No pitfall'); + expect((await call(surface, 'pitfalls_search', { query: 'x', store: 'steam' })).result!.isError).toBe(true); + expect((await call(surface, 'pitfalls_search', { query: 'a' })).result!.isError).toBe(true); + expect((await call(surface, 'pitfalls_search', {})).result!.isError).toBe(true); + }); + test('pure parts', () => { + const md = '# T\n\n## One\nalpha beta\n\n## Two\nbeta beta beta google play\n'; + const s = sectionsOf(md); + expect(s.map(x => x.heading)).toEqual(['One', 'Two']); + expect(searchSections(s, 'beta')[0]!.heading).toBe('Two'); + expect(searchSections(s, 'beta', 'apple').map(x => x.heading)).toEqual(['One']); + expect(searchSections(s, ' ')).toEqual([]); + }); +}); + +describe('store_limits', () => { + const surface = publicSurface(); + test('numbers come from the code', async () => { + const all = (await call(surface, 'store_limits', {})).result!.structuredContent!.result; + expect(all.apple.textLimitsCharacters).toMatchObject({ name: 30, subtitle: 30, keywords: 100 }); + expect(all.play.textLimitsCharacters).toMatchObject({ title: 30, short: 80, full: 4000 }); + expect(JSON.stringify(all.apple.screenshots)).toContain('1290x2796'); + expect(JSON.stringify(all.play.screenshots)).toContain('1024x500'); + const apple = (await call(surface, 'store_limits', { store: 'apple' })).result!.structuredContent!.result; + expect(Object.keys(apple)).toEqual(['apple']); + expect((await call(surface, 'store_limits', { store: 'x' })).result!.isError).toBe(true); + }); +}); + +describe('listing_lint', () => { + const surface = publicSurface(); + test('apple: limits, repeats, spaces; only sent fields are judged', async () => { + const r = await call(surface, 'listing_lint', { store: 'apple', fields: { name: 'Pixel Garden', subtitle: 'Grow plants', keywords: 'garden, plants,tiny,pixel' } }); + expect(r.result!.isError).toBe(false); + const t = text(r); + expect(t).toContain('spaces around commas'); + expect(t).toContain('already indexed'); + expect(t).not.toContain('is required'); + const long = await call(surface, 'listing_lint', { store: 'apple', fields: { name: 'N'.repeat(31) } }); + expect(text(long)).toContain('ERROR name'); + expect(text(long)).toContain('31/30'); + }); + test('play: stuffing, claims, limits', async () => { + const full = Array(120).fill('puzzle').join(' ') + ' fun games to enjoy daily with friends'; + const r = await call(surface, 'listing_lint', { store: 'play', fields: { title: 'Best Puzzle', shortDescription: 'S'.repeat(81), fullDescription: full } }); + const t = text(r); + expect(t).toContain('shortDescription is 81 characters'); + expect(t).toContain('keyword stuffing'); + expect(t).toContain('"best"'); + }); + test('bad input', async () => { + expect((await call(surface, 'listing_lint', { store: 'apple', fields: {} })).result!.isError).toBe(true); + expect((await call(surface, 'listing_lint', { store: 'apple', fields: { title: 'x' } })).result!.isError).toBe(true); + expect((await call(surface, 'listing_lint', { store: 'apple', fields: { name: 5 } })).result!.isError).toBe(true); + expect((await call(surface, 'listing_lint', { store: 'apple', fields: { name: 'x'.repeat(5001) } })).result!.isError).toBe(true); + expect((await call(surface, 'listing_lint', { store: 'apple', fields: { name: 'x' }, locale: 'en US!' })).result!.isError).toBe(true); + expect((await call(surface, 'listing_lint', { fields: { name: 'x' } })).result!.isError).toBe(true); + }); +}); + +describe('aso_search (fake server only)', () => { + test('trims results, passes term, country and limit', async () => { + const fake = await fakeItunes(); + try { + const surface = publicSurface({ searchBase: fake.url }); + const r = await call(surface, 'aso_search', { term: 'pixel garden', country: 'DE', limit: 5 }); + expect(r.result!.isError).toBe(false); + const res = r.result!.structuredContent!.result.results as Record[]; + expect(res[0]).toEqual({ rank: 1, name: 'Example One', developer: 'Example Studio', id: 111, rating: 4.57, ratingCount: 1234, genre: 'Games' }); + expect(res[1]).toMatchObject({ id: 222, rating: null, ratingCount: 0 }); + expect(JSON.stringify(res)).not.toContain('com.example.one'); + expect(fake.queries[0]).toContain('term=pixel+garden'); + expect(fake.queries[0]).toContain('country=de'); + expect(fake.queries[0]).toContain('limit=5'); + expect(fake.queries[0]).toContain('entity=software'); + } finally { fake.close(); } + }); + test('bad input is refused before any request; a failing server is an error result', async () => { + const fake = await fakeItunes(); + try { + const surface = publicSurface({ searchBase: fake.url }); + for (const bad of [{}, { term: '' }, { term: 'x', country: 'usa' }, { term: 'x', limit: 0 }, { term: 'x', limit: 26 }, { term: 'x', limit: 1.5 }, { term: 'x', url: 'http://evil.example.com' }]) + expect((await call(surface, 'aso_search', bad)).result!.isError).toBe(true); + expect(fake.queries.length).toBe(0); + } finally { fake.close(); } + const dead = publicSurface({ searchBase: 'http://127.0.0.1:1/search' }); + const r = await call(dead, 'aso_search', { term: 'x' }); + expect(r.result!.isError).toBe(true); + expect(text(r)).toContain('Search failed'); + }); +}); + +describe('module design', () => { + test('mcp-public.ts has no process, runner, credential or cwd access', () => { + const src = readFileSync(new URL('../cli/mcp-public.ts', import.meta.url), 'utf8'); + expect(src).not.toMatch(/child_process|\.\/lib\/runner|\.\/lib\/rt'|\.\/lib\/credentials|\.\/lib\/broker|process\.cwd|process\.env|spawn|exec\(/); + }); + test('validatePublic', () => { + expect(validatePublic({ type: 'object', properties: { a: { type: 'string', maxLength: 2 } } }, { a: 'abc' })).toEqual(['a: at most 2 characters']); + expect(validatePublic({ type: 'object', properties: {} }, [])).toEqual(['arguments: must be an object']); + }); +}); + +describe('rate limit', () => { + test('window counts, retry-after, sweep', () => { + let t = 1_000_000; + const l = createLimiter(3, () => t); + expect([1, 2, 3].map(() => l.hit('a').ok)).toEqual([true, true, true]); + const over = l.hit('a'); + expect(over.ok).toBe(false); + expect(over.retryAfter).toBeGreaterThan(0); + expect(over.retryAfter).toBeLessThanOrEqual(60); + expect(l.hit('b').ok).toBe(true); + t += 61_000; + expect(l.hit('a').ok).toBe(true); + }); + test('client key: forwarded header only when the proxy is trusted', () => { + expect(clientKey('client-a, proxy-1', 'proxy-1', true)).toBe('client-a'); + expect(clientKey('client-a', 'proxy-1', false)).toBe('proxy-1'); + expect(clientKey(undefined, 'proxy-1', true)).toBe('proxy-1'); + }); +}); + +describe('http, public mode', () => { + const post = (port: number, body: unknown, headers: Record = {}) => + fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) }); + const base = { host: '127.0.0.1', port: 0, roots: [], project: null, allowOrigins: [], auth: { kind: 'none' } as const }; + + test('no sign-in, any origin, CORS preflight, tools/list, /health, GET 405', async () => { + const s = await startHttp({ ...base, public: { rate: 100, trustProxy: false } }); + try { + const r = await post(s.port, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, { origin: 'https://claude.example.com' }); + expect(r.status).toBe(200); + expect(r.headers.get('access-control-allow-origin')).toBe('*'); + expect(((await r.json()) as { result: { tools: unknown[] } }).result.tools.length).toBe(6); + const pre = await fetch(`http://127.0.0.1:${s.port}/mcp`, { method: 'OPTIONS', headers: { origin: 'https://claude.example.com', 'access-control-request-method': 'POST' } }); + expect(pre.status).toBe(204); + expect((await fetch(`http://127.0.0.1:${s.port}/health`)).status).toBe(200); + expect((await fetch(`http://127.0.0.1:${s.port}/mcp`)).status).toBe(405); + const c = await post(s.port, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'check', arguments: { project: '/' } } }); + expect(((await c.json()) as { error: { message: string } }).error.message).toContain('Unknown tool'); + expect((await post(s.port, { jsonrpc: '2.0', method: 'notifications/initialized' })).status).toBe(202); + } finally { s.close(); } + }); + + test('the bare host is the endpoint too: POST / answers MCP, GET / names the connect URL', async () => { + const s = await startHttp({ ...base, public: { rate: 100, trustProxy: false } }); + try { + const r = await fetch(`http://127.0.0.1:${s.port}/`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) }); + expect(r.status).toBe(200); + expect(((await r.json()) as { result: { tools: unknown[] } }).result.tools.length).toBe(6); + const g = await fetch(`http://127.0.0.1:${s.port}/`); + expect(g.status).toBe(200); + expect(await g.text()).toContain('claude mcp add --transport http storebound'); + const b = await fetch(`http://127.0.0.1:${s.port}/`, { headers: { accept: 'text/html,application/xhtml+xml' }, redirect: 'manual' }); + expect(b.status).toBe(302); + expect(b.headers.get('location')).toBe('https://storebound.dev/'); + expect((await fetch(`http://127.0.0.1:${s.port}/other`)).status).toBe(404); + } finally { s.close(); } + }); + + test('a signed-in server keeps / closed', async () => { + const s = await startHttp({ ...base, auth: { kind: 'token', token: 't' } as const }); + try { expect((await fetch(`http://127.0.0.1:${s.port}/`)).status).toBe(404); } finally { s.close(); } + }); + + test('429 with Retry-After over the limit; X-Forwarded-For counts only with trustProxy', async () => { + const s = await startHttp({ ...base, public: { rate: 2, trustProxy: false } }); + try { + const ping = (h: Record = {}) => post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, h); + expect((await ping({ 'x-forwarded-for': 'client-a' })).status).toBe(200); + expect((await ping({ 'x-forwarded-for': 'client-b' })).status).toBe(200); // not trusted: same client + const over = await ping({ 'x-forwarded-for': 'client-c' }); + expect(over.status).toBe(429); + expect(Number(over.headers.get('retry-after'))).toBeGreaterThan(0); + expect((await fetch(`http://127.0.0.1:${s.port}/health`)).status).toBe(200); // health is not limited + } finally { s.close(); } + const t = await startHttp({ ...base, public: { rate: 1, trustProxy: true } }); + try { + const ping = (ip: string) => post(t.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { 'x-forwarded-for': `${ip}, proxy-1` }); + expect((await ping('client-a')).status).toBe(200); + expect((await ping('client-b')).status).toBe(200); + expect((await ping('client-a')).status).toBe(429); + } finally { t.close(); } + }); + + test('body over 64 KB is refused; bad JSON is a parse error', async () => { + const s = await startHttp({ ...base, public: { rate: 100, trustProxy: false } }); + try { + const big = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping', params: { pad: 'x'.repeat(PUBLIC_MAX_BODY) } }); + expect((await post(s.port, big)).status).toBe(413); + expect((await post(s.port, '{nope')).status).toBe(400); + } finally { s.close(); } + }); + + test('auth none needs public mode', async () => { + expect(() => startHttp({ ...base })).toThrow('only for --public'); + }); +}); + +test('App Store search keeps one shared budget of 20 calls a minute', async () => { + const { searchBudget } = await import('../cli/mcp-public'); + const t0 = Date.now() + 3_600_000; // after any real call earlier in this file + let last: ReturnType = { ok: true }; + for (let i = 0; i < 25; i++) last = searchBudget(t0 + i); + expect(last.ok).toBe(false); + expect(searchBudget(t0 + 61_000).ok).toBe(true); +}); diff --git a/tests/next-build-platforms.test.ts b/tests/next-build-platforms.test.ts new file mode 100644 index 0000000..7b4fa28 --- /dev/null +++ b/tests/next-build-platforms.test.ts @@ -0,0 +1,73 @@ +// One app record, an iOS and a macOS App Store version (pitfall 79): CFBundleVersion must be unique across ALL the app's +// builds, so `apple next-build`, `apple build` and `apple build --platform macos` (also --dry) share one computation. +// Seen live: the macOS dry run proposed a build number an uploaded macOS build already had (it asked only for macOS +// builds), while next-build said the right one. Against the fake App Store Connect. +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import type { ShipConfig } from '../cli/lib/config'; +import { appNextBuild, run as nextBuild } from '../cli/apple/next-build'; +import { identityLine, run as buildMas } from '../cli/apple/build-mas'; +import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; + +const APP = '6400000101'; +const CONFIG: ShipConfig = { ...BASE }; +const TAURI = JSON.stringify({ productName: 'Example Notes', identifier: 'com.example.notes', version: '1.2.0' }); +let s: Stores; +beforeAll(ascEnv); +afterAll(restore); +beforeEach(() => { s = installStores(); }); +afterEach(uninstallStores); + +const build = (id: string, version: string, state = 'VALID') => s.asc.put({ type: 'builds', id, attributes: { version, processingState: state }, rels: { app: APP } }); +/** iOS builds 202610261700 / ...1730, a macOS build ...1731 and an expired one ...1734 in another state. */ +function seed(): void { + build('b-ios-1', '202610261700'); + build('b-ios-2', '202610261730'); + build('b-mac-1', '202610261731'); + build('b-mac-2', '202610261734', 'PROCESSING'); +} + +describe('next build number over every platform', () => { + test('apple next-build and the macOS dry run agree and sit above the highest build', async () => { + seed(); + const p = project(CONFIG, { 'src-tauri/tauri.conf.json': TAURI }); + const nb = await capture(() => nextBuild(p.ctx(['--json']))); + expect(JSON.parse(nb.out)).toEqual({ next: '202610261735', builds: 4 }); + const dry = await capture(() => buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--dry', '--json']), 'linux')); + expect(JSON.parse(dry.out.slice(dry.out.indexOf('{')))).toMatchObject({ platform: 'MAC_OS', build: '202610261735' }); + }); + test('no request narrows the build list to one platform', async () => { + seed(); + const p = project(CONFIG, { 'src-tauri/tauri.conf.json': TAURI }); + await capture(() => buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--dry', '--json']), 'linux')); + const reads = s.calls.filter(c => /\/v1\/builds/.test(c.path)); + expect(reads.length).toBeGreaterThan(0); + for (const r of reads) expect(decodeURIComponent(r.path)).not.toMatch(/preReleaseVersion|platform|processingState/); + }); + test('every page counts', async () => { + const pages: Record = { + '/p1': { data: [{ attributes: { version: '5' } }], links: { next: '/p2' } }, + '/p2': { data: [{ attributes: { version: '41' } }], links: { next: '/p3' } }, + '/p3': { data: [{ attributes: { version: '9' } }], links: {} }, + }; + const seen: string[] = []; + const client = { app: async () => APP, all: async (path: string) => { seen.push(path); const out: unknown[] = []; for (let u: string | undefined = '/p1'; u; u = (pages[u] as { links: { next?: string } }).links.next) out.push(...(pages[u] as { data: unknown[] }).data); return out; } }; + expect(await appNextBuild(client as never)).toEqual({ next: '42', builds: 3 }); + expect(seen[0]).toContain(`filter[app]=${APP}`); + }); + test('an app without builds starts at 1', async () => { + expect((await appNextBuild({ app: async () => APP, all: async () => [] } as never)).next).toBe('1'); + }); +}); + +describe('identity line off a Mac', () => { + test('a non-macOS machine is called a non-build machine, not "none found"', () => { + const line = identityLine({ platform: 'win32', appIdentity: null, installerIdentity: null, profile: null }); + expect(line).toContain('not a build machine'); + expect(line).toContain('Mac'); + expect(line).not.toContain('none found'); + }); + test('on a Mac a missing identity is still reported as missing', () => { + expect(identityLine({ platform: 'darwin', appIdentity: null, installerIdentity: 'Mac Installer', profile: null })).toContain('app identity: (none found); installer identity: Mac Installer'); + }); +}); diff --git a/tests/path-free.test.ts b/tests/path-free.test.ts new file mode 100644 index 0000000..a6b599d --- /dev/null +++ b/tests/path-free.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { archiveScanDirs, derivedDataFor, leakNeedles, leakProblem, outsideHome, pathFreeSettings, prefixMaps, scanForPaths } from '../cli/lib/path-free'; +import { archiveArgs, readLastBuild, recordLastBuild, uploadSearchDirs, validateAppleBuild } from '../cli/lib/xcode'; + +const resolve = (p: string): string => (p.startsWith('/') ? p : `/proj/${p}`); +const t = { framework: 'native' as const, project: 'App.xcodeproj', scheme: 'App', notes: [] }; + +describe('derived data', () => { + test('flag beats config beats /DerivedData; without a chosen out Xcode keeps its default', () => { + expect(derivedDataFor({ flag: '/dd', config: 'cfg', out: '/o', outChosen: true, resolve })).toBe('/dd'); + expect(derivedDataFor({ config: 'cfg/dd', out: '/o', outChosen: true, resolve })).toBe('/proj/cfg/dd'); + expect(derivedDataFor({ out: '/shared/ci/out', outChosen: true, resolve })).toBe('/shared/ci/out/DerivedData'); + expect(derivedDataFor({ out: '/proj/build/ios', outChosen: false, resolve })).toBeNull(); + }); + test('archive gets -derivedDataPath only when set', () => { + const base = { target: t, root: '/p', configuration: 'Release', archivePath: '/o/App.xcarchive', build: '1', auth: null }; + const withDd = archiveArgs({ ...base, derivedData: '/o/DerivedData' }); + expect(withDd.slice(withDd.indexOf('-archivePath'), withDd.indexOf('-archivePath') + 4)).toEqual(['-archivePath', '/o/App.xcarchive', '-derivedDataPath', '/o/DerivedData']); + expect(archiveArgs(base)).not.toContain('-derivedDataPath'); + expect(archiveArgs({ ...base, derivedData: null })).not.toContain('-derivedDataPath'); + }); + test('config keys are validated', () => { + expect(validateAppleBuild({ derivedData: 'dd', pathFree: true })).toEqual([]); + expect(validateAppleBuild({ derivedData: ' ' })).toHaveLength(1); + expect(validateAppleBuild({ pathFree: 'yes' as unknown as boolean })).toHaveLength(1); + }); + test('outsideHome', () => { + expect(outsideHome('/srv/shared/ci/out', '/Users/you')).toBe(true); + expect(outsideHome('/Users/you/proj/out', '/Users/you')).toBe(false); + expect(outsideHome('/Users/you-too/out', '/Users/you')).toBe(true); + }); +}); + +describe('prefix maps', () => { + const i = { home: '/Users/you', root: '/Users/you/work/app', out: '/Users/you/work/app/build/ios', derivedData: '/Users/you/work/app/build/ios/DerivedData' }; + test('longest first (swiftc takes the first match; clang gets the list reversed)', () => { + expect(prefixMaps(i).map(m => m[0])).toEqual(['/Users/you/work/app/build/ios/DerivedData', '/Users/you/work/app/build/ios', '/Users/you/work/app', '/Users/you']); + }); + test('duplicates and a root slash are dropped', () => { + expect(prefixMaps({ home: '/h/', root: '/h', out: '/h', derivedData: null }).map(m => m[0])).toEqual(['/h']); + }); + test('settings carry swift and clang flags and keep $(inherited)', () => { + const s = pathFreeSettings(i); + expect(s.map(x => x.split('=')[0])).toEqual(['SWIFT_SERIALIZE_DEBUGGING_OPTIONS', 'OTHER_SWIFT_FLAGS', 'OTHER_CFLAGS', 'OTHER_CPLUSPLUSFLAGS']); + expect(s[0]!).toBe('SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO'); + expect(s[1]!).toContain('$(inherited) -debug-prefix-map /Users/you/work/app/build/ios/DerivedData=/derived -file-prefix-map /Users/you/work/app/build/ios/DerivedData=/derived'); + expect(s[1]!.endsWith('-file-prefix-map /Users/you=/home')).toBe(true); + expect(s[1]!.indexOf('app=.')).toBeLessThan(s[1]!.indexOf('/Users/you=/home')); + // clang: the last match wins, so the home folder comes first + expect(s[2]!.indexOf('-ffile-prefix-map=/Users/you=/home')).toBeLessThan(s[2]!.indexOf('-ffile-prefix-map=/Users/you/work/app/build/ios/DerivedData=/derived')); + expect(s.join()).not.toContain('DEBUG_INFORMATION_FORMAT'); + }); + test('a path with spaces is quoted', () => { + const s = pathFreeSettings({ home: '/Users/you', root: '/Users/you/Visual Studio Workspaces/app', out: '/Users/you/o' }); + expect(s[1]!).toContain('"/Users/you/Visual Studio Workspaces/app=."'); + expect(s[2]!).toContain('"-ffile-prefix-map=/Users/you/Visual Studio Workspaces/app=."'); + }); +}); + +describe('leak scan', () => { + const dir = mkdtempSync(join(tmpdir(), 'pf-')); + const arch = join(dir, 'App.xcarchive'); + mkdirSync(join(arch, 'Products/Applications/App.app'), { recursive: true }); + mkdirSync(join(arch, 'dSYMs/App.app.dSYM'), { recursive: true }); + mkdirSync(join(arch, 'Symbols'), { recursive: true }); + writeFileSync(join(arch, 'Products/Applications/App.app/App'), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from('/Users/you/work/app/Sources/A.swift'), Buffer.from([0]), Buffer.from('/Users/you/Library/Developer/Xcode/DerivedData/x')])); + writeFileSync(join(arch, 'dSYMs/App.app.dSYM/clean'), 'DW_AT_comp_dir ./Sources /Applications/Xcode.app/Contents'); + writeFileSync(join(arch, 'Symbols/0.symbols'), 'path /Users/you/work/app/B.swift'); + const needles = leakNeedles({ home: '/Users/you', user: 'you', root: '/Users/you/work/app' }); + + test('needles are named by kind; /Users// is dropped when it equals the home folder', () => { + expect(needles.map(n => n.kind)).toEqual(['home folder', 'checkout']); + }); + test('a shared build folder outside the home folder is not a needle (dsymutil / module cache paths there are fine)', () => { + expect(leakNeedles({ home: '/Users/you', user: 'you', root: '/Users/Shared/ship/app/abc' }).map(n => n.kind)).toEqual(['home folder']); + expect(leakNeedles({ home: '/Users/you', user: 'you', root: '/opt/builds/you/app' }).map(n => n.kind)).toEqual(['home folder', 'checkout']); + }); + test('counts hits, keeps first offsets per file, scans app, dSYMs and Symbols', () => { + const r = scanForPaths(archiveScanDirs(arch), needles); + expect(r.files).toBe(2); + expect(r.total).toBeGreaterThanOrEqual(5); + const app = r.hits.filter(h => h.file === 'Applications/App.app/App'); + expect(app.length).toBe(3); + expect(app[0]!.offset).toBe(3); + expect(r.hits.some(h => h.file === '0.symbols')).toBe(true); + expect(r.hits.some(h => h.file.includes('clean'))).toBe(false); + }); + test('the report has counts, kinds, files and offsets but never the paths', () => { + const msg = leakProblem(scanForPaths(archiveScanDirs(arch), needles), 'build/App.xcarchive')!; + expect(msg).toContain('local paths left in 2 files'); + expect(msg).toContain('Applications/App.app/App @3: home folder'); + expect(msg).not.toContain('/Users/you'); + }); + test('a clean archive has no problem', () => { + const clean = join(dir, 'Clean.xcarchive'); + mkdirSync(join(clean, 'Products'), { recursive: true }); + writeFileSync(join(clean, 'Products/a'), 'x /Applications/Xcode.app/y'); + expect(leakProblem(scanForPaths(archiveScanDirs(clean), needles), 'c')).toBeNull(); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('apple upload finds the last build', () => { + test('--out wins; else the last build out, then the config out', () => { + expect(uploadSearchDirs({ flagOut: '/o', lastOut: '/l', configOut: '/c' })).toEqual([join('/o', 'export')]); + expect(uploadSearchDirs({ lastOut: '/l', configOut: '/c' })).toEqual([join('/l', 'export'), join('/c', 'export')]); + expect(uploadSearchDirs({ lastOut: '/c', configOut: '/c' })).toEqual([join('/c', 'export')]); + }); + test('build records the IPA and out in .storebound, upload reads it back', () => { + const root = mkdtempSync(join(tmpdir(), 'lb-')); + expect(readLastBuild(root)).toBeNull(); + recordLastBuild(root, { ipa: '/shared/out/export/build-31/App.ipa', out: '/shared/out', version: '1.0', build: '31' }); + expect(readLastBuild(root)).toEqual({ ipa: '/shared/out/export/build-31/App.ipa', out: '/shared/out', version: '1.0', build: '31' }); + rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/tests/play-not-sent-for-review.test.ts b/tests/play-not-sent-for-review.test.ts index 842d858..836e8cf 100644 --- a/tests/play-not-sent-for-review.test.ts +++ b/tests/play-not-sent-for-review.test.ts @@ -24,7 +24,7 @@ afterEach(() => { uninstallStores(); }); const edits = (what: string): string[] => s.calls.filter(c => c.method === 'POST' && c.path.includes(`:${what}`)).map(c => c.path.replace(/^.*\/edits\//, '')); const release = (p: ReturnType, extra: string[] = []) => - capture(() => playRelease(p.ctx(['--version-code', '5', '--internal', 'completed', '--production', 'completed', '--name', '1.0.1 (5)', '--yes', ...extra]))); + capture(() => playRelease(p.ctx(['--version-code', '5', '--internal', 'completed', '--production', 'completed', '--name', '1.0.1 (5)', '--yes', '--first-release', ...extra]))); describe('Play refuses the automatic send for review', () => { test('the 400 is recognised by its message (and a bad-request reason), nothing else', () => { diff --git a/tests/play-old-builds.test.ts b/tests/play-old-builds.test.ts new file mode 100644 index 0000000..e654d97 --- /dev/null +++ b/tests/play-old-builds.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test'; +import { oldBuildLines, oldTrackBuilds } from '../cli/play/old-builds'; +import { isAutoSendOnly, isReviewRefused } from '../cli/play/review-pending'; + +const tracks = [ + { track: 'production', releases: [{ name: '2.0.0 (10)', versionCodes: ['10'], status: 'completed' }] }, + { track: 'alpha', releases: [{ name: '1.0 (2)', versionCodes: ['2'], status: 'completed' }] }, + { track: 'beta', releases: [{ versionCodes: ['10'], status: 'completed' }] }, + { track: 'internal', releases: [{ versionCodes: ['3'], status: 'draft' }] }, + { track: 'qa-closed', releases: [{ versionCodes: ['4'], status: 'completed' }] }, +]; + +describe('old builds on testing tracks (pitfall 129)', () => { + test('active builds below production are listed with the command that replaces them; drafts and current ones are not', () => { + const old = oldTrackBuilds(tracks); + expect(old.map(o => `${o.track}:${o.versionCodes.join(',')}`)).toEqual(['alpha:2', 'qa-closed:4']); + const text = oldBuildLines(old).join('\n'); + expect(text).toContain('storebound play release --version-code 10 --alpha completed'); + expect(text).toContain('--track qa-closed completed'); + }); + test('no production release: nothing to compare', () => { + expect(oldTrackBuilds(tracks.filter(t => t.track !== 'production'))).toEqual([]); + }); + test('the "are sent for review automatically" 400 is told apart from the "cannot be sent" one', () => { + const auto = new Error('POST commit -> 400 {"error":{"message":"Changes are sent for review automatically.","errors":[{"reason":"badRequest"}]}}'); + const refused = new Error('POST commit -> 400 {"error":{"message":"Changes cannot be sent for review automatically.","errors":[{"reason":"badRequest"}]}}'); + expect(isAutoSendOnly(auto)).toBe(true); + expect(isAutoSendOnly(refused)).toBe(false); + expect(isReviewRefused(refused)).toBe(true); + }); +}); diff --git a/tests/play-post-reads.test.ts b/tests/play-post-reads.test.ts index 6322b37..5572ae1 100644 --- a/tests/play-post-reads.test.ts +++ b/tests/play-post-reads.test.ts @@ -287,7 +287,7 @@ describe('play images: tablet, TV and Wear sets are optional (play.tablets)', () expect(resolvedTablets({ play: {} }, join(phoneOnly().root, 'store/renders'))).toBe(false); }); - test('languages without a Play listing are skipped: a draft section before any call, a 404 listing as info; exit 0', async () => { + test('a draft section is skipped before any call, a language with a 404 listing is created by the run (dry: named); exit 0', async () => { playKey(); const root = mkdtempSync(join(tmpdir(), 'sb-langs-')); const sec = (name: string, code: string, marker = '') => `## ${name}\n\n${marker}\n### Play short description\n\`\`\`\nShort.\n\`\`\`\n\n### Play full description\n\`\`\`\nFull.\n\`\`\`\n\n`; @@ -309,9 +309,9 @@ describe('play images: tablet, TV and Wear sets are optional (play.tablets)', () const out = await capture(() => imagesRun({ loaded, args: parseArgs(['--dry']) })); expect(process.exitCode).toBe(before); expect(out).toContain('skipped fr-FR: no Play listing yet (machine-translated draft, not reviewed'); - expect(out).toContain('skipped de-DE: no Play listing yet (run storebound play listing first'); + expect(out).toContain('de-DE: no Play listing yet, this run creates it from the listing file with its images'); + expect(out).toContain('de-DE: would replace icon (0 -> 1)'); expect(out).toContain('en-US: would replace icon (0 -> 1)'); - expect(out).not.toMatch(/de-DE: would/); expect(calls.some(c => c.url.includes('fr-FR'))).toBe(false); expect(calls.every(readOnly)).toBe(true); expect(noListing(new Error("GET /edits/1/listings/de-DE/icon -> 404 Listing for language 'de-DE' not found."))).toBe(true); diff --git a/tests/play-public-version-match.test.ts b/tests/play-public-version-match.test.ts index 647850e..f756b8b 100644 --- a/tests/play-public-version-match.test.ts +++ b/tests/play-public-version-match.test.ts @@ -7,7 +7,7 @@ import { trainsOf } from '../cli/release'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { clearPlayPublicCache, pageVersion, pageVersionInfo, playPublicPage } from '../cli/lib/play-public'; -import { appleReleases, playReleaseLabel, playReleases, releaseTrains, sameVersion, versionKey } from '../cli/lib/portfolio'; +import { appleReleases, PLAY_NOT_LIVE, playReleaseLabel, playReleases, releaseTrains, sameVersion, versionKey } from '../cli/lib/portfolio'; import { laneOf, laneText, releasePlan, storeNow, versionHistory } from '../cli/lib/train'; const realFetch = globalThis.fetch; @@ -45,7 +45,7 @@ describe('version matching across the stores', () => { expect(p.ios?.release?.version).toBe('1.0'); expect(p.android?.release?.version).toBe('1.0.0'); expect(p.done).toBe(false); - expect(p.android!.steps.map(x => x.text).join(' ')).toContain('1.0: In review'); + expect(p.android!.steps.map(x => x.text).join(' ')).toContain('1.0: Not live yet'); }); }); @@ -53,7 +53,7 @@ describe('Google Play "Live" needs the public store page', () => { test('completed production + page 404 = in review, never live', () => { const [prod] = playReleases(PLAY_STATUS_FIRST_404); expect(prod).toMatchObject({ phase: 'review', state: 'completed', publicPage: 'notPublic', fraction: 1 }); - expect(playReleaseLabel(prod!)).toBe('In review'); + expect(playReleaseLabel(prod!)).toBe(PLAY_NOT_LIVE); const lane = laneOf('android', prod!); expect(lane.at).toBe(2); // the review stage, not live / rollout expect(lane.share).toBeNull(); @@ -77,14 +77,14 @@ describe('Google Play "Live" needs the public store page', () => { expect(playReleaseLabel(r)).toBe('Live'); }); test('rule: page 404, managed publishing off: In review; on: In review / ready to publish', () => { - expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }))).toBe('In review'); + expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }))).toBe(PLAY_NOT_LIVE); expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }, '1.1.0', 'completed', { managedPublishing: true }))).toBe('In review / ready to publish (managed publishing)'); }); test('rule: page live with an older version: In review (live: )', () => { const r = prodRel({ state: 'public', versionName: '1.0.0' }); expect(r).toMatchObject({ phase: 'review', liveVersion: '1.0.0' }); - expect(playReleaseLabel(r)).toBe('In review (live: 1.0.0)'); - expect(laneText(laneOf('android', r))).toBe('in review (live: 1.0.0)'); + expect(playReleaseLabel(r)).toBe(`${PLAY_NOT_LIVE} (live: 1.0.0)`); + expect(laneText(laneOf('android', r))).toBe(`not live yet: in review or blocked before review (the API cannot tell; see Publishing overview) (live: 1.0.0)`); expect(storeNow([r], 'android').now).toBeNull(); }); test('rule: rollout, halted, draft', () => { diff --git a/tests/ppo.test.ts b/tests/ppo.test.ts index f4c22c2..b63e096 100644 --- a/tests/ppo.test.ts +++ b/tests/ppo.test.ts @@ -1,12 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import { displaySets, skippedDuoSets } from '../cli/apple/screenshots'; +import { displaySets, duoDirs } from '../cli/apple/screenshots'; +import { duoGroups, duoLimit, duoUpToDate } from '../cli/apple/duo-library'; import { appleRules } from '../cli/lib/validate-rules'; import { checkCreate, createExperimentReq, defaultName, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq } from '../cli/lib/ppo'; import { validateConfig } from '../cli/lib/config'; import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools'; import { requiredSets } from '../cli/shots/report'; import { defaultSizes, SIZES } from '../cli/shots/lib'; -import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API, iphoneDuoNote } from '../cli/shots/spec'; +import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, iphoneDuoNote } from '../cli/shots/spec'; import { png } from './fixtures'; describe('product page optimization', () => { @@ -91,12 +92,25 @@ describe('iPhone Duo (foldable) sizes', () => { expect(checkImage(imageInfo(png(2007, 2853)), 'iphone-duo-outer').map(i => i.code)).toEqual(['size']); expect(checkImage(imageInfo(png(2007, 2853, { colorType: 6 })), 'iphone-duo-inner').map(i => i.code)).toEqual(['alpha']); }); - test('upload: no API display type yet, so the sets are skipped with the message', () => { + test('upload: no ScreenshotDisplayType; Duo goes through the App Asset Library (IPHONE_DUO group)', () => { expect(Object.values(IPHONE_DUO_DISPLAY).every(t => t === null)).toBe(true); expect(Object.keys(displaySets({ iphoneDuo: true }))).not.toContain('iphone-duo-outer'); - expect(skippedDuoSets({})).toBeNull(); - expect(skippedDuoSets({ iphoneDuo: true })).toEqual({ dirs: ['iphone-duo-outer', 'iphone-duo-inner'], msg: expect.stringContaining(`iphone-duo-outer, iphone-duo-inner: ${IPHONE_DUO_NOT_IN_API}. Add them in App Store Connect > the iOS version page > App Previews and Screenshots > iPhone Duo: https://`) }); - expect(skippedDuoSets({ iphoneDuo: true, appId: '1234567890' })!.msg).toContain('/apps/1234567890/distribution/ios/version/inflight'); + expect(duoDirs({})).toEqual([]); + expect(duoDirs({ iphoneDuo: true })).toEqual(['iphone-duo-outer', 'iphone-duo-inner']); + const ref = [{ attributes: { placementProfileGroups: [ + { placementProfileGroupId: 'g-67', platform: 'IOS', displayClassId: 'IPHONE_DYNAMIC_ISLAND_LARGE_DISPLAY' }, + { placementProfileGroupId: 'g-duo', platform: 'IOS', displayClassId: 'IPHONE_DUO' }, + { placementProfileGroupId: 'g-duo', platform: 'IOS', displayClassId: 'IPHONE_DUO' }, + { placementProfileGroupId: 'g-mac', platform: 'MAC_OS', displayClassId: 'IPHONE_DUO' }] } }]; + expect(duoGroups(ref)).toEqual(['g-duo']); + expect(duoGroups([])).toEqual([]); + expect(duoGroups([{ attributes: { placementProfileGroups: [{ placementProfileGroupId: 'IPHONE_DUO_PROFILE', platform: 'IPHONE_APP_STORE', displayClassId: 'IPHONE_DUO' }] } }])).toEqual(['IPHONE_DUO_PROFILE']); + const lim = [{ attributes: { features: [{ featureId: 'IN_APP_EVENTS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 1 }] }] }, { featureId: 'APP_STORE_VERSIONS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 20 }] }] }] } }]; + expect(duoLimit(lim, 'g')).toBe(20); + expect(duoLimit(lim, 'other')).toBeNull(); + expect(duoUpToDate(['a', 'b'], ['a', 'b'])).toBe(true); + expect(duoUpToDate(['b', 'a'], ['a', 'b'])).toBe(false); + expect(duoUpToDate(['a', undefined], ['a', 'b'])).toBe(false); }); test('check / validate note: info before April 2027, never a blocker; points Play to the tablet sets', () => { const before = new Date('2026-10-18'), after = new Date('2027-04-02'); @@ -106,7 +120,7 @@ describe('iPhone Duo (foldable) sizes', () => { expect(n.level).toBe('info'); expect(n.msg).toContain('optional now, required from April 2027 for iOS 27.1 SDK builds'); expect(n.msg).toContain('tablet screenshots'); - expect(n.msg).toContain('the App Store Connect console takes Duo screenshots and previews, the API does not yet'); + expect(n.msg).toContain('apple screenshots uploads them through the App Asset Library'); expect(iphoneDuoNote({ apple: true, duo: false, today: after })!.level).toBe('warn'); const base = { version: null, build: null, locales: [], expectedLocales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } }; const f = appleRules({ ...base, today: before } as unknown as Parameters[0]).find(x => x.code === 'screenshots-duo')!; diff --git a/tests/release-type-inherit.test.ts b/tests/release-type-inherit.test.ts new file mode 100644 index 0000000..f2a8f76 --- /dev/null +++ b/tests/release-type-inherit.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'bun:test'; +import { planVersion } from '../cli/lib/listing-plan'; + +const row = (id: string, versionString: string, state: string, releaseType: string) => ({ id, attributes: { versionString, appStoreState: state, appVersionState: state, platform: 'IOS', releaseType } }); + +test('a new version keeps the release type of the newest version before it unless apple.releaseType is set', () => { + const rows = [row('v1', '2.0.0', 'READY_FOR_DISTRIBUTION', 'MANUAL'), row('v2', '2.0.1', 'READY_FOR_DISTRIBUTION', 'AFTER_APPROVAL')]; + const p = planVersion(rows, '2.0.2', { copyright: 'c' }, 'IOS'); + expect(p.action).toBe('create'); + expect(p.attributes.releaseType).toBe('AFTER_APPROVAL'); + expect(p.reason).toContain('as version 2.0.1'); + const set = planVersion(rows, '2.0.2', { copyright: 'c', releaseType: 'MANUAL' }, 'IOS'); + expect(set.attributes.releaseType).toBe('MANUAL'); + expect(set.warning).toContain('differs from version 2.0.1 (AFTER_APPROVAL)'); + expect(planVersion([], '1.0.0', { copyright: 'c' }, 'IOS').attributes.releaseType).toBe('MANUAL'); +}); diff --git a/tests/resubmit.test.ts b/tests/resubmit.test.ts index ac9259f..fa068e6 100644 --- a/tests/resubmit.test.ts +++ b/tests/resubmit.test.ts @@ -45,7 +45,11 @@ const CONFIG: ShipConfig = { /** The live shape: version 1.0.0 REJECTED (Invalid Binary) with build 7, submission rs-old UNRESOLVED_ISSUES holding the * version and the gems IAP; pro was never added; build 8 uploaded and VALID. */ +/** The app was on sale before (an update, not a first release): version 0.9.0 READY_FOR_SALE. */ +function onSale(): void { s.asc.put({ type: 'appStoreVersions', id: 'ver-0', attributes: { versionString: '0.9.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } }); } + function rejected(o: { pro?: boolean; ver?: string; gems?: string } = {}): void { + onSale(); s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'REJECTED', platform: 'IOS' }, rels: { app: APP } }); s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP, appStoreVersion: 'ver-1' } }); s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); @@ -181,6 +185,7 @@ describe('apple resubmit --build ', () => { describe('apple submit --build (a first submission, the desktop owner action)', () => { /** Version 1.0.0 in Prepare for Submission without a build, build 8 VALID, the gems IAP ready, no open submission. */ function fresh(): void { + onSale(); s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } }); s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); s.asc.put({ type: 'inAppPurchases', id: 'iap-gems', attributes: { productId: 'com.example.notes.gems', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' }, rels: { app: APP } }); @@ -199,7 +204,7 @@ describe('apple submit --build (a first submission, the desktop owner action expect(r.out).toMatch(/submission\s+new draft\s+-\s+create/); expect(r.out).toMatch(/app version 1\.0\.0/); expect(r.out).toContain('com.example.notes.gems'); - expect(r.out).toContain('Submitting is the owner'); + expect(r.out).toContain('update of an app on sale'); process.env.STOREBOUND_MACHINE = '1'; try { const j = await capture(() => appleSubmit(p.ctx(['--build', '8', '--json']))); @@ -210,6 +215,26 @@ describe('apple submit --build (a first submission, the desktop owner action expect(shown(a)).toEqual([]); }); + test('macOS with apple.macos.noIap and no Mac Game Center: version and build only, the skips named', async () => { + fresh(); + s.asc.put({ type: 'appStoreVersions', id: 'ver-m', attributes: { versionString: '2.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'MAC_OS' }, rels: { app: APP } }); + s.asc.put({ type: 'builds', id: 'b-13', attributes: { version: '13', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); + const p = project({ ...ONE, apple: { ...ONE.apple!, macos: { noIap: true } } }); + const a = s.calls.length; + const r = await capture(() => appleSubmit(p.ctx(['--build', '13', '--platform', 'macos']))); + expect(r.out).toContain('1 in-app purchase(s) left out: apple.macos.noIap'); + expect(r.out).not.toContain('com.example.notes.gems'); + expect(r.out).toMatch(/app version 2\.0\.0/); + expect(shown(a)).toEqual([]); + expect(s.calls.slice(a).some(c => /gameCenter/.test(c.path))).toBe(false); + }); + test('macosSubmitSkips: only MAC_OS, gameCenter true keeps the switch', async () => { + const { macosSubmitSkips } = await import('../cli/lib/config'); + expect(macosSubmitSkips({ apple: { ...ONE.apple!, macos: { noIap: true } } }, 'IOS')).toEqual({ iaps: null, gameCenter: null }); + expect(macosSubmitSkips({ apple: { ...ONE.apple!, macos: { gameCenter: true } } }, 'MAC_OS')).toEqual({ iaps: null, gameCenter: null }); + expect(macosSubmitSkips({ apple: ONE.apple! }, 'MAC_OS').gameCenter).toContain('no Game Center'); + }); + test('--yes: build attached, one draft with the version and the IAP, submitted; a re-run sends nothing', async () => { fresh(); const p = project(ONE); diff --git a/tests/ship-step.test.ts b/tests/ship-step.test.ts index 133b817..851da8c 100644 --- a/tests/ship-step.test.ts +++ b/tests/ship-step.test.ts @@ -235,6 +235,8 @@ describe('idempotent re-upload (fake stores)', () => { const LISTING = '# Play\n\n## English\n\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n'; test('play upload: first run uploads (exit 0), the re-run is exit 7 with the existing release, no second upload', async () => { + s.play.live.bundles = [41]; + s.play.live.tracks = { production: { releases: [{ status: 'completed', versionCodes: ['41'] }] } }; // published: an update const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') }); const first = await ship('play upload', ['--json', '--yes', '--track', 'production'], a => playUpload({ loaded: p.loaded, args: a })); expect(first.code).toBe(0); diff --git a/tests/signing-targets.test.ts b/tests/signing-targets.test.ts new file mode 100644 index 0000000..8f681b3 --- /dev/null +++ b/tests/signing-targets.test.ts @@ -0,0 +1,578 @@ +// Signable targets of an Xcode project (app + share extension + App Group entitlements): `apple signing ensure` +// registers the extension's bundle id, enables the capabilities its entitlements need, creates its profile and checks +// the App Group against the profile (the API cannot assign a group to a bundle id); `apple build` over SSH names the +// keychain fixes; `storebound api` writes say how an agent confirms. Fixtures are placeholders (com.example.*). +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { toXmlPlist } from '../cli/lib/plist'; +import { run as signing, discoverTargets, profileEntitlements } from '../cli/apple/signing'; +import { decide, NOT_A_TTY } from '../cli/api/gate'; +import { USAGE } from '../cli/api'; +import { keychainPassword, KEYCHAIN_PASSWORD_ENV, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../cli/lib/keychain'; +import { planProfile, validateSigning } from '../cli/lib/signing'; +import { appGroupGaps, appGroupGapsFor, appGroupSteps, appGroupVerdicts, bundleIdPortalUrl, capabilityGroups, pbxTargets, targetKind } from '../cli/lib/targets'; +import { consolePlan, planText } from '../cli/lib/console-plan'; +import { verifyAppGroupsApi, verifyText } from '../cli/lib/console-verify'; +import { PLAN_FORM_IDS } from '../cli/lib/console-answers'; +import { CONSOLE_PLAN_FORMS, TOOLS, validateInput } from '../cli/mcp-tools'; +import { upsertTiming } from '../cli/lib/upsert'; +import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock'; + +const APP_ENT = ` + + aps-environmentdevelopment + com.apple.security.application-groupsgroup.com.example.notes +`; +const SHARE_ENT = ` + + com.apple.security.application-groupsgroup.com.example.notes + keychain-access-groups$(AppIdentifierPrefix)com.example.notes + com.apple.developer.associated-domainsapplinks:example.com +`; + +/** A pbxproj the way Xcode 15 writes it: app, share extension, a unit test bundle, a framework, one variable bundle id. */ +const PBX = `// !$*UTF8*$! +{ + archiveVersion = 1; + objects = { +/* Begin PBXNativeTarget section */ + AAAAAAAAAAAAAAAAAAAAAA01 /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC01 /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( ); + name = App; + productName = App; + productType = "com.apple.product-type.application"; + }; + AAAAAAAAAAAAAAAAAAAAAA02 /* ShareExt */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC02 /* Build configuration list for PBXNativeTarget "ShareExt" */; + name = ShareExt; + productType = "com.apple.product-type.app-extension"; + }; + AAAAAAAAAAAAAAAAAAAAAA03 /* AppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */; + name = AppTests; + productType = "com.apple.product-type.bundle.unit-test"; + }; + AAAAAAAAAAAAAAAAAAAAAA04 /* Kit */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */; + name = Kit; + productType = "com.apple.product-type.framework"; + }; + AAAAAAAAAAAAAAAAAAAAAA05 /* Widget */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC05 /* x */; + name = Widget; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ +/* Begin XCBuildConfiguration section */ + BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.notes.debug; }; + name = Debug; + }; + BBBBBBBBBBBBBBBBBBBBBB02 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "{braces}", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.notes; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + BBBBBBBBBBBBBBBBBBBBBB03 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Share/Share.entitlements"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.notes.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + BBBBBBBBBBBBBBBBBBBBBB04 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "$(MY_BASE_ID).widget"; }; + name = Release; + }; +/* End XCBuildConfiguration section */ +/* Begin XCConfigurationList section */ + CCCCCCCCCCCCCCCCCCCCCC01 /* x */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */, + BBBBBBBBBBBBBBBBBBBBBB02 /* Release */, + ); + }; + CCCCCCCCCCCCCCCCCCCCCC02 /* x */ = { + isa = XCConfigurationList; + buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB03 /* Release */, ); + }; + CCCCCCCCCCCCCCCCCCCCCC03 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( ); }; + CCCCCCCCCCCCCCCCCCCCCC05 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB04 ); }; +/* End XCConfigurationList section */ + }; +} +`; + +const files = { 'ios/App/App.xcodeproj/project.pbxproj': PBX, 'ios/App/App/App.entitlements': APP_ENT, 'ios/App/Share/Share.entitlements': SHARE_ENT }; + +describe('pbxproj targets', () => { + test('targetKind: only signable product types', () => { + expect(targetKind('com.apple.product-type.application')).toBe('app'); + expect(targetKind('com.apple.product-type.app-extension')).toBe('extension'); + expect(targetKind('com.apple.product-type.app-extension.messages-sticker-pack')).toBe('extension'); + expect(targetKind('com.apple.product-type.application.watchapp2')).toBe('watch'); + expect(targetKind('com.apple.product-type.application.on-demand-install-capable')).toBe('clip'); + expect(targetKind('com.apple.product-type.framework')).toBeNull(); + expect(targetKind('com.apple.product-type.bundle.unit-test')).toBeNull(); + }); + + test('app + share extension + widget: bundle ids resolved from Release, entitlements paths, tests and frameworks skipped', () => { + const t = pbxTargets(PBX); + expect(t.map(x => x.name)).toEqual(['App', 'ShareExt', 'Widget']); + expect(t[0]).toMatchObject({ kind: 'app', bundleId: 'com.example.notes', entitlements: 'App/App.entitlements' }); + expect(t[1]).toMatchObject({ kind: 'extension', bundleId: 'com.example.notes.ShareExt', entitlements: 'Share/Share.entitlements' }); + expect(t[2]).toMatchObject({ bundleId: null, unresolved: '$(MY_BASE_ID).widget' }); + }); + + test('discoverTargets reads the entitlements: capabilities and app groups per target; unresolved ids are noted', () => { + const p = project({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101' } }, files); + const s = discoverTargets(p.root, p.loaded.config); + expect(s.main).toMatchObject({ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] }); + expect(s.main!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'PUSH_NOTIFICATIONS']); + expect(s.extensions).toHaveLength(1); + expect(s.extensions[0]).toMatchObject({ bundleId: 'com.example.notes.ShareExt', kind: 'extension', source: 'xcode', appGroups: ['group.com.example.notes'] }); + expect(s.extensions[0]!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']); // keychain-access-groups has no capability + expect(s.notes.join('\n')).toContain('Widget has bundle id "$(MY_BASE_ID).widget"'); + }); + + test('apple.signing.extensions replaces the detected extensions; extraBundleIds are added', () => { + const cfg = { ...BASE, apple: { bundleId: 'com.example.notes', signing: { extensions: [{ bundleId: 'com.example.notes.widget', capabilities: ['PUSH_NOTIFICATIONS' as const], appGroups: ['group.com.example.notes'] }], extraBundleIds: ['com.example.notes.watch'] } } }; + const p = project(cfg, files); + const s = discoverTargets(p.root, p.loaded.config); + expect(s.extensions.map(t => t.bundleId)).toEqual(['com.example.notes.widget', 'com.example.notes.watch']); + expect(s.extensions[0]).toMatchObject({ source: 'config', appGroups: ['group.com.example.notes'], capabilities: [{ type: 'PUSH_NOTIFICATIONS' }] }); + }); + + test('validateSigning checks the extensions override', () => { + expect(validateSigning({ extensions: [{ bundleId: 'com.example.app.share', capabilities: ['APP_GROUPS'], appGroups: ['group.com.example.app'] }] })).toEqual([]); + const e = validateSigning({ extensions: [{ bundleId: 'bad id', capabilities: ['NOPE' as never], appGroups: ['x'] }, { bundleId: 'com.example.a', capabilities: ['NOPE' as never], appGroups: ['x'] }] }); + expect(e.join('\n')).toContain('"bad id" is not a bundle id'); + expect(e.join('\n')).toContain('unknown capability "NOPE"'); + expect(e.join('\n')).toContain('appGroups are ids like group.'); + }); +}); + +describe('App Group check', () => { + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00trailer`, 'latin1').toString('base64'); + const profile = (ent: Record) => ({ id: 'P1', attributes: { name: 'p', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z', profileContent: prov(ent) } }); + + test('the profile plist is decoded and compared with the target\'s groups', () => { + const ent = profileEntitlements([profile({ 'application-identifier': 'ABCDE12345.com.example.notes.share', 'com.apple.security.application-groups': ['group.com.example.other'] })])!; + expect(appGroupGaps(['group.com.example.notes', 'group.com.example.other'], ent)).toEqual(['group.com.example.notes']); + expect(appGroupGaps(['group.com.example.notes'], { 'com.apple.security.application-groups': ['*'] })).toEqual([]); + expect(appGroupGaps(['group.com.example.notes'], {})).toEqual(['group.com.example.notes']); + }); + + test('a managed or contentless profile is unknown, not a gap', () => { + expect(profileEntitlements([{ id: 'M', attributes: { profileType: 'IOS_APP_STORE', profileState: 'ACTIVE' }, managed: true }])).toBeNull(); + expect(appGroupGapsFor([{ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] }], () => null, () => 'X')).toEqual([]); + }); + + test('the gap carries the portal deep link and the agent-first steps with a never-press list', () => { + const g = appGroupGapsFor([{ bundleId: 'com.example.notes.share', appGroups: ['group.com.example.notes'] }], () => ({}), () => 'EXT0001'); + expect(g).toEqual([{ bundleId: 'com.example.notes.share', resourceId: 'EXT0001', groups: ['group.com.example.notes'], url: 'https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001' }]); + expect(bundleIdPortalUrl('EXT0001')).toBe(g[0]!.url!); + const steps = appGroupSteps(g[0]!).join('\n'); + expect(steps).toContain(g[0]!.url!); + expect(steps).toContain('Never press'); + expect(steps).toContain('signing ensure --refresh-profile'); + }); + + test('planProfile: --refresh-profile makes a new profile although an active one is reusable', () => { + const existing = [{ id: 'P1', certIds: ['C1'], attributes: { name: 'old', profileType: 'IOS_APP_STORE' as const, profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z' } }]; + const o = { bundleId: 'com.example.notes', bundleIdId: 'B1', certId: 'C1', type: 'IOS_APP_STORE' as const }; + expect(planProfile(existing, o).reuse?.id).toBe('P1'); + const r = planProfile(existing, { ...o, refresh: true }); + expect(r.reuse).toBeNull(); + expect(r.create?.path).toBe('/v1/profiles'); + expect(r.why).toContain('--refresh-profile'); + }); +}); + +describe('signing ensure with an extension (fake App Store Connect)', () => { + beforeEach(() => { upsertTiming.waitMs = 0; }); + afterEach(() => { restore(); upsertTiming.waitMs = 3000; }); + + const MAIN = 'BUNDLE0001', EXT = 'EXT0001'; + const cfg = (style: 'automatic' | 'manual') => ({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101', signing: { style, capabilities: ['IN_APP_PURCHASE' as const] } } }); + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64'); + + function store(o: { groupInProfile: boolean }) { + const caps: Record = { [MAIN]: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'APP_GROUPS'], [EXT]: [] }; + let registered = false; + const bundle = (id: string, ident: string) => ({ type: 'bundleIds', id, attributes: { identifier: ident, platform: 'IOS', seedId: 'ABCDE12345' }, + relationships: { bundleIdCapabilities: { data: caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}` })) } } }); + const incl = (id: string) => caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}`, attributes: { capabilityType: t, settings: null } })); + const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } }); + const routes = [ + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: () => (registered ? page([bundle(EXT, 'com.example.notes.ShareExt')], incl(EXT)) : page([])) }, + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes&/, answer: () => page([bundle(MAIN, 'com.example.notes')], incl(MAIN)) }, + { method: 'POST', match: /^\/v1\/bundleIds$/, answer: () => { registered = true; return { data: bundle(EXT, 'com.example.notes.ShareExt') }; } }, + { method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => { + const a = (c.body as { data: { attributes: { capabilityType: string }; relationships: { bundleId: { data: { id: string } } } } }).data; + caps[a.relationships.bundleId.data.id]!.push(a.attributes.capabilityType); + return { data: { type: 'bundleIdCapabilities', id: 'x', attributes: { capabilityType: a.attributes.capabilityType } } }; + } }, + { match: /^\/v1\/certificates\?/, answer: page([]) }, + { method: 'POST', match: /^\/v1\/certificates$/, answer: { data: { type: 'certificates', id: 'CERT0001', attributes: { certificateType: 'DISTRIBUTION', name: 'Apple Distribution: Example', expirationDate: '2099-01-01T00:00:00.000+00:00', certificateContent: Buffer.from('cert').toString('base64') } } } }, + { match: /^\/v1\/bundleIds\/[A-Z0-9]+\/profiles/, answer: page([]) }, + { method: 'POST', match: /^\/v1\/profiles$/, answer: (c: { body: unknown }) => { + const id = (c.body as { data: { relationships: { bundleId: { data: { id: string } } } } }).data.relationships.bundleId.data.id; + const groups = o.groupInProfile ? { 'com.apple.security.application-groups': ['group.com.example.notes'] } : {}; + return { data: { type: 'profiles', id: `PROF_${id}`, attributes: { name: `storebound ${id}`, profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: `uuid-${id}`, expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov({ 'application-identifier': `ABCDE12345.${id}`, ...groups }) } } }; + } }, + { match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 }, + ]; + return { routes: routes as never, caps, isRegistered: () => registered }; + } + + test('--dry lists every target: the extension bundle id, its capabilities and (manual) its profile', async () => { + ascEnv(); + const p = project(cfg('manual'), files); + const s = store({ groupInProfile: true }); + const calls = mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--dry']))); + const all = r.out + r.err; + expect(all).toContain('POST /v1/bundleIds'); + expect(all).toContain('register com.example.notes.ShareExt'); + expect(all).toMatch(/POST \/v1\/bundleIdCapabilities \{capabilityType="APP_GROUPS"\} # com\.example\.notes\.ShareExt, after it is registered/); + expect(all).toContain('ASSOCIATED_DOMAINS'); + expect(all).toContain('# com.example.notes.ShareExt: with the new certificate'); + expect(all).not.toContain('ASSOCIATED_DOMAINS is in ios/App/Share/Share.entitlements'); // the extension's entitlements are not compared with the app's App ID + expect(calls.filter(c => c.method !== 'GET')).toEqual([]); + }); + + test('automatic: registers the extension bundle id and its capabilities, reads them back, no profile', async () => { + ascEnv(); + const p = project(cfg('automatic'), files); + const s = store({ groupInProfile: true }); + const calls = mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--yes']))); + expect(r.exit).toBeNull(); + const writes = calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`); + expect(writes[0]).toBe('POST /v1/bundleIds'); + expect(writes.filter(w => w === 'POST /v1/bundleIdCapabilities')).toHaveLength(2); + expect(writes.some(w => /profiles|certificates/.test(w))).toBe(false); + expect(s.caps.EXT0001!.sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']); + const reg = calls.find(c => c.method === 'POST' && c.path === '/v1/bundleIds')!.body as { data: { attributes: Record } }; + expect(reg.data.attributes).toMatchObject({ identifier: 'com.example.notes.ShareExt', platform: 'IOS', name: 'Example Notes ShareExt' }); + }); + + test('manual: a profile for the app and the extension; the group missing from the profile fails with the portal link and steps', async () => { + ascEnv(); + const p = project(cfg('manual'), files); + const s = store({ groupInProfile: false }); + const calls = mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); + const all = r.out + r.err; + expect(calls.filter(c => c.method === 'POST' && c.path === '/v1/profiles')).toHaveLength(2); + expect(all).toContain('com.example.notes.ShareExt: the newest profile lacks App Group(s) group.com.example.notes'); + expect(all).toContain('console plan apple-app-group --bundle-id com.example.notes'); + expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001'); + expect(all).toContain('signing ensure --refresh-profile'); + }); + + test('manual: the profile carries the group: ensure finishes', async () => { + ascEnv(); + const p = project(cfg('manual'), files); + const s = store({ groupInProfile: true }); + mockFetch(s.routes); + const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))); + expect(r.exit).toBeNull(); + expect(r.out + r.err).toContain('signing ready'); + }); +}); + +describe('App Group association: verdicts (pure)', () => { + const t = [{ bundleId: 'com.example.notes.ShareExt', appGroups: ['group.com.example.notes'] }]; + const ent = (g: string[]) => ({ 'com.apple.security.application-groups': g }); + + test('a readable profile with the group proves it; one without it is a gap with the deep link', () => { + expect(appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, profileEnt: ent(['group.com.example.notes']) }))).toEqual({ gaps: [], unproven: [], ok: ['com.example.notes.ShareExt'] }); + const v = appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, profileEnt: {} })); + expect(v.gaps).toHaveLength(1); + expect(v.gaps[0]).toMatchObject({ bundleId: 'com.example.notes.ShareExt', groups: ['group.com.example.notes'], url: bundleIdPortalUrl('EXT0001') }); + expect(v.gaps[0]!.why).toContain('empty application-groups'); + }); + + test('capability settings that name the group prove it without a profile', () => { + const settings = [{ key: 'APP_GROUPS', options: [{ key: 'group.com.example.notes' }] }]; + expect(capabilityGroups(settings, ['group.com.example.notes', 'group.com.example.other'])).toEqual(['group.com.example.notes']); + expect(appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, capabilitySettings: settings, profileEnt: null })).ok).toEqual(['com.example.notes.ShareExt']); + }); + + test('nothing readable is unproven (not a gap); the capability off is a gap even without a profile', () => { + const u = appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, capabilitySettings: null, profileEnt: null })); + expect([u.gaps.length, u.unproven.length]).toEqual([0, 1]); + expect(u.unproven[0]!.why).toContain('cannot be proven'); + expect(appGroupVerdicts(t, () => null).unproven).toHaveLength(1); + const off = appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: false, profileEnt: null })); + expect(off.gaps[0]!.why).toContain('capability is not enabled'); + }); + + test('the steps name the portal path, the deep link, the plan command and the refresh', () => { + const s = appGroupSteps({ bundleId: 'com.example.notes.ShareExt', resourceId: 'EXT0001', groups: ['group.com.example.notes'], url: bundleIdPortalUrl('EXT0001') }).join('\n'); + expect(s).toContain('Certificates, Identifiers & Profiles > Identifiers > com.example.notes.ShareExt > App Groups'); + expect(s).toContain('Configure > tick group.com.example.notes'); + expect(s).toContain('Continue > Save'); + expect(s).toContain('storebound console plan apple-app-group --bundle-id com.example.notes.ShareExt'); + expect(s).toContain('storebound apple signing ensure --refresh-profile'); + }); +}); + +describe('App Group association under automatic signing (fake App Store Connect)', () => { + beforeEach(() => { upsertTiming.waitMs = 0; }); + afterEach(() => { restore(); upsertTiming.waitMs = 3000; }); + + const MAIN = 'BUNDLE0001', EXT = 'EXT0001'; + const cfg = { ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101', signing: { style: 'automatic' as const, teamId: 'ABCDE12345', capabilities: ['IN_APP_PURCHASE' as const] } } }; + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64'); + const managed = (id: string, ent: Record) => ({ type: 'profiles', id: `MANAGED_${id}`, attributes: { name: `iOS Team Store Provisioning Profile: ${id}`, profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: `uuid-${id}`, expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov(ent) } }); + const GROUP = { 'com.apple.security.application-groups': ['group.com.example.notes'] }; + const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } }); + + function api(o: { ext?: unknown[]; main?: unknown[]; extSettings?: unknown; extCaps?: string[] }) { + const caps: Record = { [MAIN]: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'APP_GROUPS'], [EXT]: o.extCaps ?? ['APP_GROUPS', 'ASSOCIATED_DOMAINS'] }; + const bundle = (id: string, ident: string) => ({ type: 'bundleIds', id, attributes: { identifier: ident, platform: 'IOS', seedId: 'ABCDE12345' }, relationships: { bundleIdCapabilities: { data: caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}` })) } } }); + const incl = (id: string) => caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}`, attributes: { capabilityType: t, settings: id === EXT && t === 'APP_GROUPS' ? (o.extSettings ?? null) : null } })); + return [ + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: () => page([bundle(EXT, 'com.example.notes.ShareExt')], incl(EXT)) }, + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes&/, answer: () => page([bundle(MAIN, 'com.example.notes')], incl(MAIN)) }, + { match: /^\/v1\/certificates\?/, answer: page([]) }, + { match: new RegExp(`^/v1/bundleIds/${EXT}/profiles`), answer: () => page(o.ext ?? []) }, + { match: new RegExp(`^/v1/bundleIds/${MAIN}/profiles`), answer: () => page(o.main ?? [managed('com.example.notes', GROUP)]) }, + { match: /^\/v1\/profiles\/MANAGED_[^/]+\/relationships\/certificates/, answer: { errors: [{ status: '404' }] }, status: 404 }, + { match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 }, + // apple build compares the live version's device families first: an app without versions has none + { match: /^\/v1\/apps\/6400000101\/appStoreVersions/, answer: page([]) }, + ] as never; + } + const ensure = async (argv: string[] = ['ensure', '--yes']) => { + const p = project(cfg, files); + const calls = mockFetch(api(current)); + const r = await capture(() => signing(p.ctx(argv))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); + return { r, all: r.out + r.err, calls }; + }; + let current: Parameters[0] = {}; + + test('the extension\'s newest (Xcode-managed) profile has an empty group list: ensure stops with the steps, the deep link and the plan command', async () => { + ascEnv(); + current = { ext: [managed('com.example.notes.ShareExt', {})] }; + const { r, all, calls } = await ensure(); + expect(r.exit).toBe(1); + expect(all).toContain('com.example.notes.ShareExt: the newest profile lacks App Group(s) group.com.example.notes'); + expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001'); + expect(all).toContain('Configure > tick group.com.example.notes'); + expect(all).toContain('storebound console plan apple-app-group --bundle-id com.example.notes.ShareExt'); + expect(all).toContain('storebound apple signing ensure --refresh-profile'); + expect(calls.filter(c => c.method !== 'GET')).toEqual([]); + }); + + test('the managed profile carries the group: ensure finishes without a warning', async () => { + ascEnv(); + current = { ext: [managed('com.example.notes.ShareExt', GROUP)] }; + const { r, all } = await ensure(); + expect(r.exit).toBeNull(); + expect(all).not.toContain('cannot be proven'); + expect(all).toContain('capabilities in step'); + }); + + test('no profile and no capability field to read: ensure warns with the portal steps instead of passing silently', async () => { + ascEnv(); + current = { ext: [] }; + const { r, all } = await ensure(); + expect(r.exit).toBeNull(); + expect(all).toContain('cannot be proven'); + expect(all).toContain('storebound console plan apple-app-group --bundle-id com.example.notes.ShareExt'); + }); + + test('a capability field that names the group proves it', async () => { + ascEnv(); + current = { ext: [], extSettings: [{ key: 'APP_GROUPS', options: [{ key: 'group.com.example.notes' }] }] }; + const { all } = await ensure(); + expect(all).not.toContain('cannot be proven'); + }); + + test('status --json lists the gap and the unproven targets; the main app (profile without groups) is a gap too', async () => { + ascEnv(); + current = { ext: [], main: [managed('com.example.notes', {})] }; + const p = project(cfg, files); + mockFetch(api(current)); + const r = await capture(() => signing(p.ctx(['status', '--json']))); + const j = JSON.parse(r.out.slice(r.out.indexOf('{'))); + expect(j.appGroupGaps.map((g: { bundleId: string }) => g.bundleId)).toEqual(['com.example.notes']); + expect(j.appGroupUnproven.map((g: { bundleId: string }) => g.bundleId)).toEqual(['com.example.notes.ShareExt']); + }); + + test('apple build stops before the archive on a proven gap, builds on --allow-app-group-gap, and only warns on an unproven one', async () => { + ascEnv(); + const { run: build } = await import('../cli/apple/build'); + const argv = ['--build-number', '5', '--no-prebuild', '--dry']; + // dry: warns (never throws) + current = { ext: [managed('com.example.notes.ShareExt', {})] }; + let p = project(cfg, files); + mockFetch(api(current)); + let r = await capture(() => build(p.ctx(argv))); + expect(r.err).toContain('the newest profile lacks App Group(s)'); + // not dry: early exit, before any xcodebuild step + p = project(cfg, files); + mockFetch(api(current)); + r = await capture(() => build(p.ctx(['--build-number', '5', '--no-prebuild']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); + expect(r.exit).toBe(1); + expect(r.err).toContain('stopped before the archive'); + expect(r.err).toContain('console plan apple-app-group --bundle-id com.example.notes.ShareExt'); + expect(r.err).toContain('--allow-app-group-gap'); + // unproven: a warning with the same steps, no throw at the group check + current = { ext: [] }; + p = project(cfg, files); + mockFetch(api(current)); + r = await capture(() => build(p.ctx(argv))); + expect(r.err).toContain('cannot be proven'); + }); +}); + +describe('console plan / verify apple-app-group', () => { + afterEach(() => { restore(); }); + + test('the plan: deep link per resource id, the portal labels, the answer sheet per extension, never-press list, Save is the agent\'s', () => { + const p = consolePlan('apple-app-group', BASE, { appGroupTargets: [{ bundleId: 'com.example.notes.ShareExt', groups: ['group.com.example.notes'], resourceId: 'EXT0001' }] }); + expect(p).toMatchObject({ form: 'apple-app-group', store: 'apple', fillable: true }); + expect(p.start).toEqual({ url: 'https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001', deepLink: true, reach: [] }); + const text = p.steps.map(s => s.text).join('\n'); + for (const label of ['App Groups', 'Configure', 'Continue', 'Save', 'Confirm']) expect(text).toContain(label); + expect(p.answers.map(a => a.id)).toEqual(['com.example.notes.ShareExt/capability', 'com.example.notes.ShareExt/group.com.example.notes']); + expect(p.answers[1]).toMatchObject({ label: 'group.com.example.notes', action: 'tick', control: 'checkbox', status: 'certain' }); + const inSteps = new Set(p.steps.flatMap(s => s.answers ?? [])); + for (const a of p.answers) expect(inSteps.has(a.id)).toBe(true); + for (const b of ['Remove', 'Delete', 'Revoke', 'Publish', 'Submit']) expect(p.buttons.neverPress).toContain(b); + expect(p.buttons.neverPress).not.toContain('Save'); + expect(p.buttons).toMatchObject({ final: 'Save', finalByAgent: true }); + for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit'); + expect(p.ownerBatch).toEqual([]); + expect(p.verify.command).toBe('storebound console verify apple-app-group --bundle-id com.example.notes.ShareExt'); + expect(planText(p)).toContain('the agent\'s own, not a store submission'); + p.steps.forEach((s, i) => expect(s.n).toBe(i + 1)); + }); + + test('without a resource id the plan starts at the Identifiers list and says --lookup; without targets the group ids are asked', () => { + const p = consolePlan('apple-app-group', BASE, { appGroupTargets: [{ bundleId: 'com.example.notes.ShareExt', groups: ['group.com.example.notes'] }] }); + expect(p.start).toMatchObject({ url: 'https://developer.apple.com/account/resources/identifiers/list', deepLink: false }); + expect(p.start.reach.join(' ')).toContain('--lookup'); + const none = consolePlan('apple-app-group', BASE); + expect(none.answers[0]).toMatchObject({ status: 'ask-owner', value: null }); + expect(none.askOwner).toHaveLength(1); + }); + + test('the plan is in the form list and the MCP tools, and the plan command works from the CLI', async () => { + expect(PLAN_FORM_IDS).toContain('apple-app-group'); + expect(CONSOLE_PLAN_FORMS).toContain('apple-app-group'); + const t = TOOLS.find(x => x.name === 'console_plan')!; + expect(validateInput(t, { form: 'apple-app-group', bundleId: 'com.example.notes.ShareExt', lookup: true })).toEqual([]); + expect(t.args!({ form: 'apple-app-group', bundleId: 'com.example.notes.ShareExt', groups: 'group.com.example.notes' })).toEqual(['apple-app-group', '--bundle-id', 'com.example.notes.ShareExt', '--groups', 'group.com.example.notes', '--json']); + const { run: plan } = await import('../cli/console/plan'); + const p = project({ ...BASE, apple: { bundleId: 'com.example.notes' } }, files); + const r = await capture(() => plan(p.ctx(['apple-app-group', '--json']))); + const j = JSON.parse(r.out); + expect(j.answers.map((a: { id: string }) => a.id)).toEqual(['com.example.notes/capability', 'com.example.notes/group.com.example.notes', 'com.example.notes.ShareExt/capability', 'com.example.notes.ShareExt/group.com.example.notes']); + const one = await capture(() => plan(p.ctx(['apple-app-group', '--bundle-id', 'com.example.notes.ShareExt', '--json']))); + expect(JSON.parse(one.out).answers).toHaveLength(2); + }); + + test('verify reads the API: present groups match, a missing one mismatches, an unprovable one fails', async () => { + const targets = [{ bundleId: 'com.example.notes.ShareExt', appGroups: ['group.com.example.notes', 'group.com.example.other'] }]; + const ok = verifyAppGroupsApi(targets, { gaps: [], unproven: [], ok: ['com.example.notes.ShareExt'] }); + expect([ok.ok, ok.matched, ok.mismatches]).toEqual([true, 2, 0]); + const gap = verifyAppGroupsApi(targets, { gaps: [{ bundleId: 'com.example.notes.ShareExt', resourceId: 'EXT0001', groups: ['group.com.example.other'], url: null, why: 'the newest profile lacks it' }], unproven: [], ok: [] }); + expect([gap.ok, gap.matched, gap.mismatches]).toEqual([false, 1, 1]); + expect(gap.rows.find(r => r.status === 'mismatch')!.item).toContain('group.com.example.other'); + const un = verifyAppGroupsApi(targets, { gaps: [], unproven: [{ bundleId: 'com.example.notes.ShareExt', resourceId: null, groups: ['group.com.example.notes', 'group.com.example.other'], url: null }], ok: [] }); + expect(un.ok).toBe(false); + expect(un.rows[0]!.note).toContain('signing ensure --refresh-profile'); + expect(() => verifyText('apple-app-group', BASE, 'x')).toThrow('reads the App Store Connect API'); + }); + + test('console verify apple-app-group end to end with a fake API: missing then present after the refreshed profile', async () => { + ascEnv(); + const { run: verify } = await import('../cli/console/verify'); + const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64'); + const prof = (ent: Record) => ({ type: 'profiles', id: 'P1', attributes: { name: 'p', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: 'u', expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov(ent) } }); + const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } }); + const run = async (ent: Record) => { + mockFetch([ + { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: page([{ type: 'bundleIds', id: 'EXT0001', attributes: { identifier: 'com.example.notes.ShareExt', platform: 'IOS' }, relationships: { bundleIdCapabilities: { data: [{ type: 'bundleIdCapabilities', id: 'c1' }] } } }], [{ type: 'bundleIdCapabilities', id: 'c1', attributes: { capabilityType: 'APP_GROUPS', settings: null } }]) }, + { match: /^\/v1\/bundleIds\/EXT0001\/profiles/, answer: page([prof(ent)]) }, + ] as never); + const p = project({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101' } }, files); + return capture(() => verify(p.ctx(['apple-app-group', '--bundle-id', 'com.example.notes.ShareExt']))); + }; + const before = await run({}); + expect(before.exit).toBe(1); + expect(before.out).toContain('mismatch'); + restore(); ascEnv(); + const after = await run({ 'com.apple.security.application-groups': ['group.com.example.notes'] }); + expect(after.exit).toBeNull(); + expect(after.out).toContain('match'); + }); +}); + +describe('apple build over SSH: the keychain fixes', () => { + test('the refusal names the GUI session, the CI runner and the keychain flags', () => { + const m = sshBuildRefusal('this is an SSH session'); + expect(m).toContain('errSecInternalComponent'); + expect(m).toContain('open -a Terminal'); + expect(m).toContain('Jenkins'); + expect(m).toContain('security unlock-keychain'); + expect(m).toContain('security set-key-partition-list'); + expect(m).toContain('--keychain-unlock'); + expect(m).toContain('--keychain '); + expect(m).toContain(KEYCHAIN_PASSWORD_ENV); + expect(sshBuildRefusal('x', { unlockFlag: false })).not.toContain('--keychain-unlock'); + }); + + test('unlock steps carry a PASSWORD placeholder that is never shown; the build keychain joins the search list', () => { + const steps = unlockSteps('/tmp/build.keychain-db', ['/Users/example/Library/Keychains/login.keychain-db']); + expect(steps.map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list', 'list-keychains']); + expect(steps[1]).toContain('apple-tool:,apple:,codesign:'); + expect(steps[2]).toEqual(['security', 'list-keychains', '-d', 'user', '-s', '/tmp/build.keychain-db', '/Users/example/Library/Keychains/login.keychain-db']); + expect(showSecurity(steps[0]!)).toBe(`security unlock-keychain -p $${KEYCHAIN_PASSWORD_ENV} /tmp/build.keychain-db`); + expect(unlockSteps('/a', ['/a']).map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list']); + expect(parseKeychainList(' "/Users/example/Library/Keychains/login.keychain-db"\n "/Library/Keychains/System.keychain"\n')).toEqual(['/Users/example/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']); + }); + + test('the password comes from the environment; its absence is an error that names the variable only', () => { + expect(() => keychainPassword({})).toThrow(KEYCHAIN_PASSWORD_ENV); + expect(keychainPassword({ [KEYCHAIN_PASSWORD_ENV]: 'pw-for-test' })).toBe('pw-for-test'); + try { keychainPassword({}); } catch (e) { expect((e as Error).message).not.toContain('pw-for-test'); } + }); +}); + +describe('storebound api writes: --yes --confirm ', () => { + const w = { store: 'apple' as const, method: 'POST', kind: 'write' as const, opId: 'bundleIds_createInstance', yes: false, tty: false, dry: false, agent: false }; + test('the usage and the refusals show the example', () => { + expect(USAGE).toContain('--yes --confirm bundleIds_createInstance'); + expect(NOT_A_TTY).toContain('--yes --confirm bundleIds_createInstance'); + const d = decide(w as never); + expect(d.action).toBe('dry'); + const why = (d as { why: string }).why; + expect(why).toContain('--yes --confirm bundleIds_createInstance'); + expect(why).toContain('storebound api apple bundleIds_createInstance --body body.json'); + }); +}); diff --git a/tests/tf-description-locale.test.ts b/tests/tf-description-locale.test.ts new file mode 100644 index 0000000..0a52026 --- /dev/null +++ b/tests/tf-description-locale.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'bun:test'; +import { betaDescriptions, validateTestflight } from '../cli/lib/testflight'; + +const langs = [{ id: 'en', apple: 'en-US' }, { id: 'tr', apple: 'tr' }]; + +test('beta descriptions take a language id or its App Store locale ("en-US" was skipped silently)', () => { + expect(betaDescriptions({ description: { 'en-US': 'Hello', tr: 'Merhaba' } }, langs)).toEqual({ 'en-US': 'Hello', tr: 'Merhaba' }); + expect(betaDescriptions({ description: { en: 'Hello' } }, langs)).toEqual({ 'en-US': 'Hello' }); +}); + +test('an unknown description key is a config error naming the accepted keys', () => { + expect(validateTestflight({ description: { 'en-US': 'x', de: 'y' } }, langs).join()).toContain("'de' is not a language of the config"); + expect(validateTestflight({ description: { 'en-US': 'x', tr: 'y' } }, langs)).toEqual([]); +}); diff --git a/tests/update-gate.test.ts b/tests/update-gate.test.ts new file mode 100644 index 0000000..0d9e237 --- /dev/null +++ b/tests/update-gate.test.ts @@ -0,0 +1,259 @@ +// Updates are agent steps, the first release is the owner's (cli/lib/release-kind.ts, docs/bypass.md "Updates are agent +// steps"): the kind (update / first) is read from the store; an update of a live app ships with --yes in a pipeline +// (no owner gate), a first-ever release with --yes is exit 3 OWNER_STEP unless approved (--first-release, bypass mode), +// and a release that is on the store already is exit 7. Against the stateful App Store Connect and Google Play doubles. +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs } from '../cli/lib/args'; +import { appleReleaseKind, firstReleaseAllowed, playReleaseKind } from '../cli/lib/release-kind'; +import { setBypass } from '../cli/lib/bypass'; +import { runShipStep } from '../cli/lib/ship-step'; +import { run as playUpload } from '../cli/play/upload'; +import { run as playRelease } from '../cli/play/release'; +import { run as appleSubmit } from '../cli/apple/submit'; +import { run as appleRelease } from '../cli/apple/release'; +import { makeAab } from './build-fixtures'; +import { ascEnv, BASE, playEnv, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores'; + +const APP = '6400000101'; + +/** Runs a ship step in-process with stdout / stderr captured. */ +async function ship(key: string, argv: string[], body: (a: ReturnType) => Promise): Promise<{ code: number; out: string; err: string }> { + const out: string[] = [], err: string[] = []; + const o = process.stdout.write, e = process.stderr.write, cl = console.log, ce = console.error, cw = console.warn, ci = console.info; + process.stdout.write = ((s: string | Uint8Array) => { out.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stdout.write; + process.stderr.write = ((s: string | Uint8Array) => { err.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stderr.write; + console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ') + '\n'); }; + console.info = console.log; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ') + '\n'); }; + console.warn = console.error; + try { + const code = await runShipStep(key, argv, parseArgs, async a => { + const keep = console.log; + if (a.has('json')) console.log = (...x: unknown[]) => console.error(...x); + try { await body(a); } finally { console.log = keep; } + }); + return { code, out: out.join(''), err: err.join('') }; + } finally { process.stdout.write = o; process.stderr.write = e; console.log = cl; console.error = ce; console.warn = cw; console.info = ci; } +} + +describe('the kind comes from the store (pure)', () => { + test('Play: a completed / inProgress / halted production release means published; a draft or nothing means first', () => { + expect(playReleaseKind([{ status: 'completed', versionCodes: ['41'] }]).kind).toBe('update'); + expect(playReleaseKind([{ status: 'inProgress', versionCodes: ['42'] }, { status: 'completed', versionCodes: ['41'] }]).kind).toBe('update'); + expect(playReleaseKind([{ status: 'halted', versionCodes: ['42'] }]).kind).toBe('update'); + expect(playReleaseKind([{ status: 'draft', versionCodes: ['1'] }])).toMatchObject({ kind: 'first', why: expect.stringContaining('only draft') }); + expect(playReleaseKind([]).kind).toBe('first'); + expect(playReleaseKind(undefined).kind).toBe('first'); + }); + test('App Store: a version that is or was on sale means update; Prepare for Submission / in review / rejected means first', () => { + expect(appleReleaseKind([{ versionString: '1.0.0', state: 'READY_FOR_SALE' }, { versionString: '1.1.0', state: 'PREPARE_FOR_SUBMISSION' }]).kind).toBe('update'); + expect(appleReleaseKind([{ state: 'REPLACED_WITH_NEW_VERSION' }]).kind).toBe('update'); + expect(appleReleaseKind([{ state: 'READY_FOR_DISTRIBUTION' }]).kind).toBe('update'); + expect(appleReleaseKind([{ state: 'PREPARE_FOR_SUBMISSION' }, { state: 'WAITING_FOR_REVIEW' }, { state: 'REJECTED' }]).kind).toBe('first'); + expect(appleReleaseKind([]).kind).toBe('first'); + }); + test('the policy: updates always; a first release only with a prompt, the owner flag or bypass mode', () => { + const g = { yes: true, bypass: false, approved: false }; + expect(firstReleaseAllowed({ ...g, kind: 'update' })).toBe(true); + expect(firstReleaseAllowed({ ...g, kind: 'first' })).toBe(false); + expect(firstReleaseAllowed({ ...g, kind: 'first', yes: false })).toBe(true); // the prompt is the owner's answer + expect(firstReleaseAllowed({ ...g, kind: 'first', approved: true })).toBe(true); + expect(firstReleaseAllowed({ ...g, kind: 'first', bypass: true })).toBe(true); + }); +}); + +describe('Google Play production (fake store)', () => { + setDefaultTimeout(30_000); + let s: Stores; + beforeAll(() => { playEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); }); + afterEach(() => { uninstallStores(); delete process.env.STOREBOUND_HOME; }); + const LISTING = '# Play\n\n## English\n\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n'; + const published = (): void => { s.play.live.bundles = [41]; s.play.live.tracks = { production: { releases: [{ status: 'completed', versionCodes: ['41'] }] } }; }; + const files = () => ({ 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') }); + const upload = ['--json', '--yes', '--track', 'production']; + + test('play upload --track production --yes on a published app: no owner gate, exit 0, exit 7 on the re-run', async () => { + published(); + const p = project(BASE, files()); + const first = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a })); + expect(first.err).not.toContain('error:'); + expect(first.code).toBe(0); + expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'upload', track: 'production', build: '42', state: 'completed' }); + expect(s.play.committed.length).toBe(1); + const n = s.calls.length; + const again = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a })); + expect(again.code).toBe(7); + expect(writes(s.calls.slice(n))).toEqual([]); + }); + + test('play upload --track production --yes on a never-published app: exit 3 OWNER_STEP, nothing written', async () => { + const p = project(BASE, files()); + const r = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a })); + expect(r.code).toBe(3); + const j = JSON.parse(r.out); + expect(j).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); + expect(j.error.message).toContain('first release of this app'); + expect(j.error.message).not.toContain('the owner sends it'); + expect(j.error.consoleUrl).toContain('play.google.com/console'); + expect(writes(s.calls)).toEqual([]); + expect(s.play.committed).toEqual([]); + }); + + test('the owner approved the first release (--first-release) or turned bypass mode on: the gate lets it through', async () => { + const p = project(BASE, files()); + const ok = await ship('play upload', [...upload, '--first-release'], a => playUpload({ loaded: p.loaded, args: a })); + // Play takes only drafts on a never-published app: the gate passed, the store's own answer is the console step + expect(ok.err).toContain('first release ('); + }); + + test('bypass mode (the owner opted in) lets a first release through the gate', async () => { + process.env.STOREBOUND_HOME = mkdtempSync(join(tmpdir(), 'sb-bypass-')); + setBypass(true); + const p2 = project(BASE, files()); + const viaBypass = await ship('play upload', upload, a => playUpload({ loaded: p2.loaded, args: a })); + expect(viaBypass.code).not.toBe(3); + expect(viaBypass.err).toContain('bypass mode'); + }); + + test('a plain run (no contract) of a first release is the usual declined exit 3', async () => { + const p = project(BASE, files()); + const before = process.exitCode; + const err: string[] = []; + const ce = console.error; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ')); }; + try { await playUpload(p.ctx(['--yes', '--track', 'production'])); } finally { console.error = ce; } + expect(process.exitCode).toBe(3); + expect(err.join('\n')).toContain('declined: first release of this app'); + process.exitCode = before; + expect(writes(s.calls)).toEqual([]); + }); + + test('play release --production completed --yes: an update goes through, then exit 7; a staged rollout is the same policy', async () => { + published(); + s.play.live.bundles = [41, 42]; + const p = project(BASE, { 'store/listing.md': LISTING }); + const argv = ['--json', '--yes', '--version-code', '42', '--production', 'completed']; + const first = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a })); + expect(first.err).not.toContain('error:'); + expect(first.code).toBe(0); + expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'release', track: 'production', build: '42', state: 'completed', releaseKind: 'update' }); + const again = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a })); + expect(again.code).toBe(7); + const staged = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'inProgress', '--fraction', '0.1'], a => playRelease({ loaded: p.loaded, args: a })); + expect(staged.code).toBe(0); + expect(JSON.parse(staged.out).tracks[0]).toMatchObject({ status: 'inProgress', userFraction: 0.1 }); + }); + + test('play release --production completed --yes on a never-published app: exit 3 OWNER_STEP; --production draft is not gated', async () => { + s.play.live.bundles = [42]; + const p = project(BASE, { 'store/listing.md': LISTING }); + const refused = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'completed'], a => playRelease({ loaded: p.loaded, args: a })); + expect(refused.code).toBe(3); + expect(JSON.parse(refused.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); + expect(s.play.committed).toEqual([]); + const draft = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'draft'], a => playRelease({ loaded: p.loaded, args: a })); + expect(draft.code).toBe(0); + }); +}); + +describe('App Store (fake store)', () => { + setDefaultTimeout(30_000); + let s: Stores; + beforeAll(() => { ascEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); }); + afterEach(() => { uninstallStores(); }); + const version = (id: string, v: string, state: string): void => { s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString: v, appVersionState: state, platform: 'IOS' }, rels: { app: APP } }); }; + const build = (): void => { s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); }; + + test('apple submit --yes for an update of an app on sale: no owner gate, exit 0; the re-run is exit 7', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); + build(); + const p = project(BASE); + const argv = ['--json', '--yes', '--build', '8']; + const r = await ship('apple submit', argv, a => appleSubmit({ loaded: p.loaded, args: a })); + expect(r.err).not.toContain('error:'); + expect(r.code).toBe(0); + expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'submit', store: 'apple', build: '8', releaseKind: 'update' }); + expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']); + const n = s.calls.length; + const again = await ship('apple submit', argv, a => appleSubmit({ loaded: p.loaded, args: a })); + expect(again.code).toBe(7); + expect(writes(s.calls.slice(n))).toEqual([]); + }); + + test('apple submit --yes for the first version of an app: exit 3 OWNER_STEP, nothing written; --first-release sends it', async () => { + version('ver-1', '1.0.0', 'PREPARE_FOR_SUBMISSION'); + build(); + const p = project(BASE); + const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(r.code).toBe(3); + const j = JSON.parse(r.out); + expect(j).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); + expect(j.error.message).toContain('first release of this app'); + expect(writes(s.calls)).toEqual([]); + expect(s.asc.all('reviewSubmissions')).toEqual([]); + const ok = await ship('apple submit', ['--json', '--yes', '--build', '8', '--first-release'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(ok.code).toBe(0); + expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']); + }); + + test('apple submit without --yes is a dry run that names the kind (no gate, exit 0)', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); + build(); + const p = project(BASE); + const r = await ship('apple submit', ['--json', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(r.code).toBe(0); + expect(JSON.parse(r.out)).toMatchObject({ state: 'planned', releaseKind: 'update' }); + expect(writes(s.calls)).toEqual([]); + }); + + test('apple release --manual-release --yes: an approved update is released (exit 0), released already is exit 7', async () => { + version('ver-0', '1.0.0', 'REPLACED_WITH_NEW_VERSION'); + version('ver-1', '1.1.0', 'PENDING_DEVELOPER_RELEASE'); + const p = project(BASE); + const argv = ['--json', '--yes', '--manual-release', '--version', '1.1.0']; + const r = await ship('apple release', argv, a => appleRelease({ loaded: p.loaded, args: a })); + expect(r.err).not.toContain('error:'); + expect(r.code).toBe(0); + expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'release', store: 'apple', version: '1.1.0', releaseKind: 'update' }); + expect(writes(s.calls).map(c => `${c.method} ${c.path}`)).toEqual(['POST /v1/appStoreVersionReleaseRequests']); + // the store moved it on: the same command again has nothing to release + s.asc.all('appStoreVersions').find(v => v.id === 'ver-1')!.attributes.appVersionState = 'READY_FOR_SALE'; + const n = s.calls.length; + const again = await ship('apple release', argv, a => appleRelease({ loaded: p.loaded, args: a })); + expect(again.code).toBe(7); + expect(JSON.parse(again.out)).toMatchObject({ ok: true, state: 'READY_FOR_SALE' }); + expect(writes(s.calls.slice(n))).toEqual([]); + }); + + test('apple release --manual-release --yes for the first version ever: exit 3 OWNER_STEP, no release request', async () => { + version('ver-1', '1.0.0', 'PENDING_DEVELOPER_RELEASE'); + const p = project(BASE); + const r = await ship('apple release', ['--json', '--yes', '--manual-release'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(r.code).toBe(3); + expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); + expect(writes(s.calls)).toEqual([]); + }); +}); + +describe('the policy is written down', () => { + const read = (f: string): string => readFileSync(join(import.meta.dir, '..', f), 'utf8'); + test('docs, skills and the agent rules say updates are agent steps and the first release is the owner\'s', () => { + for (const f of ['docs/bypass.md', 'docs/ship-step.md', 'skills/release-management/SKILL.md', 'skills/play-first-release/SKILL.md', 'docs/agents-core.md']) { + const t = read(f); + expect(t, f).toContain('--first-release'); + expect(t, f).toContain('OWNER_STEP'); + } + expect(read('skills/store-pitfalls/SKILL.md')).toContain('Updates are agent steps'); + expect(read('CHANGELOG.md')).toContain('OWNER_STEP'); + }); +}); diff --git a/tests/upload-pkg-hang.test.ts b/tests/upload-pkg-hang.test.ts new file mode 100644 index 0000000..f4e1890 --- /dev/null +++ b/tests/upload-pkg-hang.test.ts @@ -0,0 +1,114 @@ +// `apple upload --platform macos` once printed nothing for 10 minutes over SSH although the build number was +// VALID on App Store Connect. Causes: pkgutil --check-signature / --expand ran unbounded and silent, and the processing +// wait polled the IOS platform only, so a Mac build was "not listed yet" for the whole --timeout. These tests keep +// every slow step logged and bounded, and an already-VALID build number ends at once (no upload, no wait). +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { hooks, run as appleUpload } from '../cli/apple/upload'; +import { PKG_CHECK_MS, PKG_EXPAND_MS, pkgInfo } from '../cli/lib/mas'; +import { HEARTBEAT_MS } from '../cli/lib/progress'; +import type { SyncResult } from '../cli/lib/rt'; +import { ascEnv, BASE, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores'; + +const SIDE = JSON.stringify({ bundleId: 'com.example.notes', version: '1.2.0', build: '7' }); +const files = (): Record => ({ 'build/macos/Example.pkg': 'pkg', 'build/macos/Example.pkg.json': SIDE }); +const result = (o: Partial): SyncResult => ({ exitCode: 0, signalCode: null, success: true, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), ...o }); +const timedOut = result({ exitCode: null, signalCode: 'SIGTERM', success: false }); +const SIGNED = 'Package "Bare.pkg":\n Status: signed by a certificate trusted by macOS\n Certificate Chain:\n 1. 3rd Party Mac Developer Installer: Example (ABCDE12345)\n Expires: 2030-01-01\n 2. Apple Worldwide Developer Relations Certification Authority\n 3. Apple Root CA\n'; + +type Seen = { argv: string[]; timeout?: number }; +const fake = (seen: Seen[], answer: (argv: string[]) => SyncResult) => ((argv: string[], o: { timeout?: number }) => { seen.push({ argv, timeout: o.timeout }); return answer(argv); }) as never; + +/** Runs fn with log output captured (log.ts writes through console). */ +async function logged(fn: () => Promise): Promise<{ text: string; error: Error | null }> { + const out: string[] = []; + const log = console.log, err = console.error, warn = console.warn; + console.log = console.error = console.warn = (...a: unknown[]) => { out.push(a.map(String).join(' ')); }; + let error: Error | null = null; + try { await fn(); } catch (e) { error = e as Error; } finally { console.log = log; console.error = err; console.warn = warn; } + return { text: out.join('\n'), error }; +} + +describe('pkgInfo: bounded and never silent', () => { + test('the signature check has a timeout and a log line; when it times out the .json apple build wrote is used', () => { + const p = project(BASE, files()); + const said: string[] = [], seen: Seen[] = []; + const info = pkgInfo(join(p.root, 'build/macos/Example.pkg'), 'com.example.notes', { say: m => said.push(m), spawnSync: fake(seen, () => timedOut) }); + expect(info).toEqual({ bundleId: 'com.example.notes', version: '1.2.0', build: '7' }); + expect(seen).toHaveLength(1); + expect(seen[0]!.timeout).toBe(PKG_CHECK_MS); + expect(said[0]).toContain('pkgutil --check-signature'); + expect(said.at(-1)).toContain('gave no answer'); + expect(said.at(-1)).toContain('Example.pkg.json'); + }); + + test('the expand is bounded too; a timeout without the .json file is an error that names the cause', () => { + const p = project(BASE, { 'build/macos/Bare.pkg': 'pkg' }); + const said: string[] = [], seen: Seen[] = []; + const spawnSync = fake(seen, argv => (argv[1] === '--check-signature' ? result({ stdout: Buffer.from(SIGNED) }) : timedOut)); + let error: Error | null = null; + try { pkgInfo(join(p.root, 'build/macos/Bare.pkg'), undefined, { say: m => said.push(m), spawnSync, expandMs: 5000 }); } catch (e) { error = e as Error; } + expect(error?.message).toContain('pkgutil --expand gave no answer'); + expect(error?.message).toContain('Bare.pkg.json'); + expect(seen.map(x => x.argv[1])).toEqual(['--check-signature', '--expand']); + expect(seen[1]!.timeout).toBe(5000); + expect(said.some(m => m.includes('pkgutil --expand'))).toBe(true); + expect(PKG_EXPAND_MS).toBeGreaterThan(PKG_CHECK_MS); + }); +}); + +describe('apple upload of a .pkg (fake stores)', () => { + setDefaultTimeout(30_000); + let s: Stores; + const path = process.env.PATH; + beforeAll(() => { ascEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); process.env.PATH = mkdtempSync(join(tmpdir(), 'sb-nopkgutil-')); }); // no pkgutil: the .json is read + afterEach(() => { uninstallStores(); process.env.PATH = path; hooks.heartbeat = undefined; }); + + test('an already VALID build number ends at once: clear message, no upload, no wait, every slow step logged first', async () => { + s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } }); + const p = project(BASE, files()); + const started = Date.now(); + const r = await logged(() => appleUpload(p.ctx(['build/macos/Example.pkg', '--platform', 'macos', '--yes']))); + expect(Date.now() - started).toBeLessThan(10_000); + expect(r.error?.message).toContain('already on App Store Connect (VALID)'); + expect(writes(s.calls)).toEqual([]); + expect(s.calls.some(c => decodeURIComponent(c.path).includes('preReleaseVersion.platform'))).toBe(false); // no processing poll + expect(s.asc.parts).toEqual([]); + expect(r.text).toContain('reading the package'); + expect(r.text).toContain('asking App Store Connect'); + expect(r.text.indexOf('reading the package')).toBeLessThan(r.text.indexOf('asking App Store Connect')); + }); + + test('--allow-existing on a Mac build: the wait polls MAC_OS and prints a heartbeat while a poll is in flight', async () => { + s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } }); + const p = project(BASE, files()); + let beat: (() => void) | null = null; + hooks.heartbeat = { everyMs: 1000, ticker: f => { beat = f; return () => { beat = null; }; } }; + const inner = globalThis.fetch, polls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + if (decodeURIComponent(String(input)).includes('preReleaseVersion.platform')) { + (beat as (() => void) | null)?.(); // the poll is in flight: time passes + polls.push(decodeURIComponent(String(input))); + // the fake does not implement include=: answer the way App Store Connect does + return new Response(JSON.stringify({ data: [{ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'pv-1' } } } }], included: [{ type: 'preReleaseVersions', id: 'pv-1', attributes: { version: '1.2.0' } }] }), { status: 200 }); + } + return inner(input as never, init); + }) as typeof fetch; + const r = await logged(() => appleUpload(p.ctx(['build/macos/Example.pkg', '--platform', 'macos', '--yes', '--allow-existing']))); + expect(r.error).toBeNull(); + expect(r.text).toMatch(/still waiting for build 7: first check/); + expect(r.text).toContain('build 7 is VALID'); + expect(polls).toHaveLength(1); + expect(polls[0]).toContain('filter[preReleaseVersion.platform]=MAC_OS'); + expect(writes(s.calls)).toEqual([]); + }); + + test('the default heartbeat is at least once a minute', () => { + expect(HEARTBEAT_MS).toBeLessThanOrEqual(60_000); + }); +}); diff --git a/tests/validate-macos.test.ts b/tests/validate-macos.test.ts new file mode 100644 index 0000000..baae419 --- /dev/null +++ b/tests/validate-macos.test.ts @@ -0,0 +1,52 @@ +// A macOS App Store version is checked against the Mac screenshot set only (APP_DESKTOP), never the iPhone / iPad +// sets: validate / release plan showed 2 false blockers per locale for an app whose editable version was macOS. +// Also: a non-macOS machine without the certificates' keys must not be told to create another certificate. +import { describe, expect, test } from 'bun:test'; +import { requiredDisplayTypes } from '../cli/apple/screenshots'; +import { pickCertificate, type ApiCert } from '../cli/lib/signing'; +import { appleRules, type AppleSnapshot } from '../cli/lib/validate-rules'; + +const snap = (platform: string, apple: Parameters[0], shots: Record): AppleSnapshot => ({ + version: { versionString: '1.0.0', state: 'PREPARE_FOR_SUBMISSION', platform }, build: null, expectedLocales: ['en-US', 'de-DE'], + locales: ['en-US', 'de-DE'].map(locale => ({ locale, name: 'n', subtitle: 's', description: 'd', keywords: 'k', screenshots: shots })), + requiredDisplayTypes: requiredDisplayTypes(apple, platform), mac: !!apple?.mac, macVersion: platform === 'MAC_OS', + reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, +} as AppleSnapshot); +const shotBlockers = (s: AppleSnapshot) => appleRules(s).filter(x => x.level === 'blocker' && x.code.startsWith('screenshots')); + +describe('required screenshot types per platform', () => { + test('macOS: the Mac set with apple.mac, nothing else; apple.screenshotTypes stays the iOS list', () => { + expect(requiredDisplayTypes({ ipad: true, mac: true }, 'MAC_OS')).toEqual(['APP_DESKTOP']); + expect(requiredDisplayTypes({ ipad: true, mac: true, screenshotTypes: ['APP_IPHONE_67'] }, 'MAC_OS')).toEqual(['APP_DESKTOP']); + expect(requiredDisplayTypes({ ipad: true }, 'MAC_OS')).toEqual([]); + expect(requiredDisplayTypes({ ipad: true, mac: true, screenshotTypes: ['APP_IPHONE_67'] })).toEqual(['APP_IPHONE_67']); + expect(requiredDisplayTypes({ ipad: true })).toEqual(['APP_IPHONE_61', 'APP_IPAD_PRO_3GEN_129']); + expect(requiredDisplayTypes({ ipad: true }, 'TV_OS')).toEqual([]); + }); + test('a macOS version with Mac screenshots and no iPhone / iPad ones has no screenshot blocker', () => { + expect(shotBlockers(snap('MAC_OS', { ipad: true, mac: true }, { APP_DESKTOP: 3 }))).toEqual([]); + }); + test('a macOS version without the Mac set is missing APP_DESKTOP only', () => { + const b = shotBlockers(snap('MAC_OS', { ipad: true, mac: true }, {})); + expect(b.map(x => x.msg)).toEqual(['en-US: no APP_DESKTOP screenshots', 'de-DE: no APP_DESKTOP screenshots']); + }); + test('an iOS version still needs the iPhone and iPad sets', () => { + expect(shotBlockers(snap('IOS', { ipad: true, mac: true }, { APP_DESKTOP: 3 })).length).toBe(4); + }); +}); + +describe('certificate without a local key', () => { + const c = (id: string): ApiCert => ({ id, attributes: { certificateType: 'DISTRIBUTION', activated: true, expirationDate: new Date(Date.now() + 400 * 864e5).toISOString() } }); + test('macOS: a new one or the .p12 import', () => { + expect(pickCertificate([c('a')], 'DISTRIBUTION', () => false, Date.now(), 'darwin').why).toContain('a new one is needed'); + }); + test('Windows / Linux: build on the Mac, never a new certificate', () => { + for (const p of ['win32', 'linux']) { + const why = pickCertificate([c('a')], 'DISTRIBUTION', () => false, Date.now(), p).why; + expect(why).toContain('none of their private keys'); + expect(why).toContain('cannot build or sign for Apple'); + expect(why).toContain('Do not create another certificate'); + expect(why).not.toContain('a new one is needed'); + } + }); +}); diff --git a/tests/validate-play-edit.test.ts b/tests/validate-play-edit.test.ts new file mode 100644 index 0000000..fb21da1 --- /dev/null +++ b/tests/validate-play-edit.test.ts @@ -0,0 +1,91 @@ +// `storebound validate --play` read its screenshots with a bare POST /edits and a DELETE in a finally: not under the +// machine lock and never retried. When Play (or another storebound process) ended that edit meanwhile, the next read +// answered 400 "This Edit has been deleted" and validate printed it as a store blocker ("GET .../phoneScreenshots 400"). +// Now the reads run in play.read (one locked edit, deleted only after all reads, redone once with a new edit), and a +// vanished edit that stays gone is a tool warning, never a blocker. +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { generateKeyPairSync } from 'node:crypto'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs } from '../cli/lib/args'; +import type { Loaded, ShipConfig } from '../cli/lib/config'; +import { collectPlay, run } from '../cli/validate'; +import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; + +const PKG = 'com.example.game'; +const GONE = { error: { code: 400, status: 'FAILED_PRECONDITION', message: 'This Edit has been deleted.' } }; +let s: Stores; +let savedKey: string | undefined; + +beforeEach(() => { + savedKey = process.env.PLAY_SERVICE_ACCOUNT; + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' }); + s = installStores(); + // the fake has no countryAvailability route (the console owns it): a plain 404, which validate reads as "not readable" + s.faults.push({ method: 'GET', match: /countryAvailability/, status: 404, when: 'before', times: 99 }); + s.play.live.listings['en-US'] = { language: 'en-US', title: 'Example Game', shortDescription: 'Short.', fullDescription: 'Full.' }; +}); +afterEach(() => { + uninstallStores(); + if (savedKey === undefined) delete process.env.PLAY_SERVICE_ACCOUNT; else process.env.PLAY_SERVICE_ACCOUNT = savedKey; +}); + +function loaded(): Loaded { + const root = mkdtempSync(join(tmpdir(), 'sb-vpe-')); + const config = { app: { name: 'Example Game', privacyPolicyUrl: '' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG } } as unknown as ShipConfig; + return { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; +} + +/** Play ends the edit under the reader: the DELETE happens on the store, the read that follows answers 400. */ +function endEditOnImageRead(times: number): { hits: () => number } { + const inner = globalThis.fetch; + let left = times, hits = 0; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const m = /\/edits\/([^/]+)\/listings\/[^/]+\/phoneScreenshots$/.exec(url); + if (m && (init?.method ?? 'GET').toUpperCase() === 'GET' && left > 0) { + left--; hits++; + s.play.edits.delete(m[1]!); + return new Response(JSON.stringify(GONE), { status: 400, headers: { 'content-type': 'application/json' } }); + } + return inner(input, init); + }) as typeof fetch; + return { hits: () => hits }; +} + +describe('validate --play: the read-only edit', () => { + test('an edit ended during the screenshot reads is reopened once; the snapshot is complete and every edit is closed', async () => { + const fault = endEditOnImageRead(1); + const snap = await collectPlay({ loaded: loaded(), args: parseArgs(['--play']) }); + expect(fault.hits()).toBe(1); + expect(snap.listings.map(l => l.language)).toEqual(['en-US']); + expect(Object.keys(snap.listings[0]!.images)).toContain('phoneScreenshots'); + expect(s.play.edits.size).toBe(0); + const edits = s.calls.filter(c => c.method === 'POST' && /\/edits$/.test(c.path)); + expect(edits.length).toBe(2); + }); + + test('no image read runs after the edit was deleted', async () => { + await collectPlay({ loaded: loaded(), args: parseArgs(['--play']) }); + const del = s.calls.findIndex(c => c.method === 'DELETE' && /\/edits\/[^/]+$/.test(c.path)); + expect(del).toBeGreaterThan(-1); + expect(s.calls.slice(del + 1).filter(c => /\/edits\/[^/]+\//.test(c.path))).toEqual([]); + }); + + test('an edit that stays gone is a tool warning, never a store blocker', async () => { + endEditOnImageRead(10); + const out: string[] = []; + const log = console.log; + console.log = (...a: unknown[]) => { out.push(a.join(' ')); }; + const prev = process.exitCode; + try { await run({ loaded: loaded(), args: parseArgs(['--play', '--json', '--no-scan', '--no-health', '--no-local']) }); } finally { console.log = log; } + process.exitCode = prev; + const res = JSON.parse(out.join('\n').slice(out.join('\n').indexOf('{'))) as { blockers: number; findings: { level: string; code: string; msg: string }[] }; + expect(res.findings.filter(f => f.level === 'blocker' && /Edit has been deleted|phoneScreenshots/.test(f.msg))).toEqual([]); + expect(res.findings).toContainEqual(expect.objectContaining({ level: 'warn', code: 'edit-gone' })); + // (the template-answers blocker of the example config is unrelated: only an api blocker would be the bug) + expect(res.findings.filter(f => f.level === 'blocker' && f.code === 'api')).toEqual([]); + }); +}); diff --git a/tests/walk-skip.test.ts b/tests/walk-skip.test.ts new file mode 100644 index 0000000..0dea615 --- /dev/null +++ b/tests/walk-skip.test.ts @@ -0,0 +1,29 @@ +import { afterAll, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { projectSdkManifests } from '../cli/lib/privacy-files'; +import { listFiles } from '../cli/lib/scan/project'; +import { skipDir } from '../cli/lib/walk-skip'; + +const root = mkdtempSync(join(tmpdir(), 'walk-')); +afterAll(() => rmSync(root, { recursive: true, force: true })); +const put = (rel: string, text = 'x') => { mkdirSync(join(root, rel, '..'), { recursive: true }); writeFileSync(join(root, rel), text); }; + +test('dot folders, linked worktrees and SDK folders', () => { + put('.harness-worktrees/a/src/App.swift'); + put('wt/.git', 'gitdir: /repo/.git/worktrees/wt\n'); + put('wt/src/App.swift'); + put('src/App.swift'); + put('ios/Pods/Some/PrivacyInfo.xcprivacy', ''); + expect(skipDir(root, join(root, '.harness-worktrees'), '.harness-worktrees')).toBe(true); + expect(skipDir(root, join(root, 'wt'), 'wt')).toBe(true); + expect(skipDir(root, join(root, 'src'), 'src')).toBe(false); + expect(skipDir(root, join(root, 'ios', 'Pods'), 'Pods')).toBe(false); + const files = listFiles(root); + expect(files).toContain('src/App.swift'); + expect(files.some(f => f.startsWith('.harness-worktrees/') || f.startsWith('wt/'))).toBe(false); + // worktree copies would add their SDK manifests a second time + put('.wt-head/ios/Pods/Some/PrivacyInfo.xcprivacy', ''); + expect(projectSdkManifests(root).map(m => m.manifest.path)).toEqual(['ios/Pods/Some/PrivacyInfo.xcprivacy']); +}); diff --git a/tests/wave1.test.ts b/tests/wave1.test.ts index 894e88b..788ed78 100644 --- a/tests/wave1.test.ts +++ b/tests/wave1.test.ts @@ -290,6 +290,15 @@ describe('validate rules', () => { expect(f.find(x => x.code === 'availability')!.fix).toContain('Pricing and Availability'); expect(f.findIndex(x => x.level === 'ok')).toBeGreaterThan(f.findLastIndex(x => x.level === 'blocker')); }); + test('apple: products removed from sale are not blockers, unless the config still sells them', () => { + const s = appleOk(); + s.iaps = [...s.iaps, { productId: 'old1', state: 'DEVELOPER_REMOVED_FROM_SALE', reviewScreenshot: false }, { productId: 'old2', state: 'REMOVED_FROM_SALE', reviewScreenshot: false }]; + expect(appleRules(s).filter(f => f.level !== 'ok')).toEqual([]); + expect(appleRules(s).find(f => f.code === 'iap')!.msg).toBe('1 in-app purchases ready (2 removed from sale, not checked)'); + s.expectedProducts = ['p1', 'old1']; + const f = appleRules(s).find(x => x.code === 'iap')!; + expect([f.level, f.msg]).toEqual(['warn', 'old1: DEVELOPER_REMOVED_FROM_SALE, but the config still sells it']); + }); test('apple: non-exempt needs an approved declaration', () => { const s = appleOk(); s.build = { ...s.build!, usesNonExemptEncryption: true, declarationState: 'IN_REVIEW' }; diff --git a/tools/ci-install-bun.sh b/tools/ci-install-bun.sh new file mode 100644 index 0000000..8071050 --- /dev/null +++ b/tools/ci-install-bun.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Installs bun $1 (or $BUN_VERSION) into ~/.bun/bin on a Linux CI runner from the npm registry (@oven/bun-linux-), +# because GitHub's oven-sh/bun release assets answer 504 now and then; bun.sh/install with retries is the fallback. +set -u +V="${1:-${BUN_VERSION:?BUN_VERSION}}" +case "$(uname -m)" in aarch64|arm64) A=aarch64 ;; *) A=x64 ;; esac +D="$HOME/.bun/bin"; mkdir -p "$D"; T=$(mktemp -d) +for i in 1 2 3; do + if curl -fsSL --max-time 120 "https://registry.npmjs.org/@oven/bun-linux-$A/-/bun-linux-$A-$V.tgz" | tar -xz -C "$T" \ + && install -m 755 "$T/package/bin/bun" "$D/bun"; then ln -sf bun "$D/bunx"; "$D/bun" --version; exit 0; fi + sleep 10 +done +for i in 1 2 3; do curl -fsSL https://bun.sh/install | bash -s "bun-v$V" >/dev/null && { "$D/bun" --version; exit 0; }; sleep 20; done +echo "bun $V: npm registry and GitHub both failed" >&2; exit 1 diff --git a/tools/ci-tauri-tools.sh b/tools/ci-tauri-tools.sh new file mode 100755 index 0000000..63f6841 --- /dev/null +++ b/tools/ci-tauri-tools.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Puts the AppImage tools Tauri's bundler would download from GitHub releases (which answer 502/504 now and then) into +# ~/.cache/tauri from our Forgejo package mirror, checked against its SHA256SUMS. Tauri skips the download when they exist. +set -eu +B="${TAURI_TOOLS_MIRROR:-https://git.crt.fyi/api/packages/CRT/generic/tauri-linux-tools/2026.11.08}" +D="$HOME/.cache/tauri"; mkdir -p "$D"; cd "$D" +curl -fsSL --retry 3 --max-time 60 -o SHA256SUMS "$B/SHA256SUMS" +while read -r sum f; do + f="${f#\*}" + if ! echo "$sum $f" | sha256sum -c --status 2>/dev/null; then curl -fsSL --retry 3 --max-time 300 -o "$f" "$B/$f"; echo "$sum $f" | sha256sum -c --status; fi + chmod +x "$f" +done < SHA256SUMS +rm -f SHA256SUMS; ls "$D" diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index 6f7ced0..4c86acf 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -244,20 +244,51 @@ async function newPage(ctx: { newPage(): Promise }): return page; } -/** Opens an app tab like a person: the tab itself when it is in the row, else through the row's "More" menu (tabs - * that do not fit the window go there). */ +/** The app's places (docs IA): five sections, each with sub views in a segmented control. A place is named by its + * sub view's old tab id ("tab-prices"); the section of each, for opening it like a person. */ +const SECTION_OF: Record = { + releases: 'release', testflight: 'release', listings: 'store', art: 'store', appinfo: 'store', catalog: 'revenue', prices: 'revenue', + reviews: 'players', games: 'players', health: 'health', growth: 'health', checks: 'health', tools: 'health', +}; + +/** Opens a place in an app like a person: its section in the section row (or through the row's "More" menu when it + * does not fit), then the sub view in the section's segmented control when the section has more than one. */ async function clickTab(page: Page, id: string, o: { timeout?: number } = {}): Promise { - const tab = page.locator(`[role="tab"]#${id}`); + const sub = id.replace(/^tab-/, ''); + const sec = SECTION_OF[sub]; + if (!sec) throw new Error(`unknown place ${id}`); + const tab = page.locator(`[role="tab"]#tab-${sec}`); await tab.waitFor({ state: 'attached', timeout: o.timeout }); - if (await tab.isVisible()) { await tab.click(o); return; } - const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim(); - try { - await page.locator('.ds-tab-more').click(o); - await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o); - } catch (e) { - const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth })); - throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`); + if (await tab.isVisible()) await tab.click(o); + else { + const label = ((await tab.textContent()) ?? '').replace(/\d+$/, '').trim(); + try { + await page.locator('.ds-tab-more').click(o); + await page.locator('.ds-menu [role="menuitem"]', { hasText: label }).first().click(o); + } catch (e) { + const why = await page.evaluate(() => JSON.stringify({ more: document.querySelectorAll('.ds-tab-more').length, menu: [...document.querySelectorAll('.ds-menu [role^="menuitem"]')].map(x => x.textContent), w: innerWidth })); + throw new Error(`${String(e).split('\n')[0]} (${label}; ${why})`); + } } + const btn = page.locator(`.sec-subs #sub-${sub}`); + if (await btn.count()) { await btn.click(o); return; } + // a section with one sub view has no segmented control: the section itself is the place + if (await page.locator('.sec-subs button').count()) throw new Error(`${id}: not in the ${sec} section's views`); +} + +/** Every place of the open app, in order: each section, then each of its sub views (as clickTab ids). */ +async function places(page: Page, o: { timeout?: number } = {}): Promise { + const secs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id.replace(/^tab-/, ''))); + const out: string[] = []; + for (const sec of secs) { + await page.locator(`[role="tab"]#tab-${sec}`).click(o).catch(() => {}); + await page.locator('[data-testid="section-head"]').waitFor({ state: 'visible', timeout: o.timeout }).catch(() => {}); + const subs = await page.locator('.sec-subs button').evaluateAll(bs => bs.map(b => b.id.replace(/^sub-/, ''))); + const first = Object.entries(SECTION_OF).find(([, s]) => s === sec)?.[0]; + // a one-view section: its only view (Players without games is Reviews) + out.push(...(subs.length ? subs : first ? [first] : []).map(x => `tab-${x}`)); + } + return out; } async function state(page: Page) { @@ -342,10 +373,12 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br if (await wizard.count()) problems.push('the welcome guide does not close with Escape'); } // every sidebar entry: the portfolio, each app (folders that are gone included), each workspace screen - const count = await page.locator('nav.side button.nav').count(); + // (apps are .nav rows; the workspace is the footer's icon row, named by aria-label) + const SIDE = 'nav.side button.nav, nav.side button.nav-ws'; + const count = await page.locator(SIDE).count(); for (let i = 0; i < count; i++) { - const b = page.locator('nav.side button.nav').nth(i); - const label = ((await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim(); + const b = page.locator(SIDE).nth(i); + const label = ((await b.getAttribute('aria-label').catch(() => null)) || (await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim(); await b.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`)); await settle(page); await check(label); @@ -365,7 +398,7 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br // shared with the CLI: each account names its credential profile (or that its old keychain key has not moved) const accts = fx.accounts_list as { label?: string; profile?: string | null }[]; if (accts.length) { - const nav = page.locator('nav.side button.nav', { hasText: 'Accounts' }).first(); + const nav = page.locator('nav.side [data-screen="accounts"]').first(); if (await nav.count()) { await nav.click({ timeout: WAIT_MS }); await settle(page); @@ -510,8 +543,8 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' await gone(page, '.cp-panel'); await crashCheck('Releases'); visited++; - // every tab of the app, then App info's console steps and the Dependencies screen - const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id)); + // every place of the app (each section and its sub views), then App info's console steps and Dependencies + const tabs = await places(page, { timeout: WAIT_MS }); for (const id of tabs) { await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`)); await settle(page); @@ -524,7 +557,7 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' if (id === 'tab-testflight' && !(await text()).length) problems.push('TestFlight: empty'); } notes.push(`tabs: ${tabs.join(', ')}`); - const deps = page.locator('nav.side button.nav', { hasText: 'Dependencies' }).first(); + const deps = page.locator('nav.side [data-screen="dependencies"]').first(); await deps.click({ timeout: WAIT_MS }).catch(() => {}); await shows(page, '#content', 'Android SDK'); await settle(page); @@ -581,7 +614,7 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404: let visited = 0; const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' '); const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); }; - const SENT = 'In review'; // decided from the store page: 404 = in review (no uncertain wording) + const SENT = 'Not live yet: in review or blocked before review'; // store page 404: the API has no review state (pitfall 129) try { await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' }); await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)')); @@ -589,9 +622,10 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404: const wizard = page.locator('.wizard[role="dialog"]'); if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); } // the portfolio: Play's cell names the state, never "Live" while the page is 404 - await shows(page, '#content', page404 ? SENT : '1.0.0'); + // (the overview counts it with the reviews; its own words are on Releases, checked below) + await shows(page, '#content', '1.0.0'); const pf = await text(); - if (page404 && !pf.includes(SENT)) problems.push(`Portfolio: no "${SENT}"`); + if (page404 && !pf.includes('Live versions 0')) problems.push('Portfolio: Play counted as live while its store page is 404'); await crashCheck('Portfolio'); visited++; const item = page.locator('nav.side .nav-app').filter({ hasText: /Prism Puddle|demo-game/ }).first(); @@ -710,7 +744,7 @@ async function runAutoReadCase(browser: Browser): Promise { await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`)); await settle(page); const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim(); - const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id)); + const tabs = await places(page, { timeout: WAIT_MS }); for (const id of tabs) { await clickTab(page, id, { timeout: WAIT_MS }).catch(e => problems.push(`${appName} ${id}: ${String(e).split('\n')[0]}`)); await settle(page); @@ -943,7 +977,7 @@ async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise problems.push(`no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="listings"]')).slice(0, 600)})`)); - await page.waitForFunction(() => !document.querySelector('#tab-listings .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('the unsaved mark stays after Save')); + await page.waitForFunction(() => !document.querySelector('#sub-listings .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('the unsaved mark stays after Save')); await settle(page); const md = readFileSync(appstore, 'utf8'); const changed = md.split('\n').filter((l, i) => l !== before.md.split('\n')[i]); @@ -989,7 +1023,7 @@ async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise problems.push(`App info: no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="appinfo"]')).slice(0, 600)})`)); - await page.waitForFunction(() => !document.querySelector('#tab-appinfo .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('App info: the unsaved mark stays after Save')); + await page.waitForFunction(() => !document.querySelector('#sub-appinfo .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('App info: the unsaved mark stays after Save')); const cfg = readFileSync(join(dir, 'storebound.config.ts'), 'utf8'); const cfgChanged = cfg.split('\n').filter((l, i) => l !== before.cfg.split('\n')[i]); if (!cfg.includes("reviewNotes: 'Sign in with the demo account.'") || cfgChanged.length !== 1 || !cfg.includes('// storebound config of the demo app')) problems.push(`storebound.config.ts: expected one changed line with reviewNotes and the comments kept, got ${JSON.stringify(cfgChanged).slice(0, 300)}`); diff --git a/tools/desktop-dev.ts b/tools/desktop-dev.ts index 29f8a30..fd089f6 100644 --- a/tools/desktop-dev.ts +++ b/tools/desktop-dev.ts @@ -62,10 +62,14 @@ export function pruneDev(releases: T[], keep = K } async function liveDev(): Promise { - const r = await fetch(`${DEV_URL}?t=${Date.now()}`, { signal: AbortSignal.timeout(20_000) }).catch(() => null); - if (!r || r.status === 404) return null; - if (!r.ok) throw new Error(`${DEV_URL}: HTTP ${r.status}`); - return ((await r.json()) as { version?: string }).version ?? null; + // a 5xx from the CDN / origin is often a blip: try a few times before giving up + for (let i = 0; ; i++) { + const r = await fetch(`${DEV_URL}?t=${Date.now()}`, { signal: AbortSignal.timeout(20_000) }).catch(() => null); + if (r?.status === 404) return null; + if (r?.ok) return ((await r.json()) as { version?: string }).version ?? null; + if (i >= 4) throw new Error(`${DEV_URL}: ${r ? `HTTP ${r.status}` : 'no answer'} after ${i + 1} tries`); + await new Promise(res => setTimeout(res, 5_000 * (i + 1))); + } } function commitDate(): Date { diff --git a/tools/desktop-release.ts b/tools/desktop-release.ts index 21de8e1..0db4cb6 100644 --- a/tools/desktop-release.ts +++ b/tools/desktop-release.ts @@ -220,7 +220,11 @@ function updaterKey(): string { function signFile(file: string): string { const key = updaterKey(); rmSync(`${file}.sig`, { force: true }); - run(['bunx', 'tauri', 'signer', 'sign', '-f', key, '-p', '', file], { cwd: APP_DIR, env: { ...process.env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' } }); + // the key goes by path only: a TAURI_SIGNING_PRIVATE_KEY left in the env (a bundle step, the shell) made the signer + // refuse "--private-key-path cannot be used with --private-key" (0.8.59) + const env: Record = { ...process.env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' }; + delete env.TAURI_SIGNING_PRIVATE_KEY; delete env.TAURI_SIGNING_PRIVATE_KEY_PATH; + run(['bunx', 'tauri', 'signer', 'sign', '-f', key, '-p', '', file], { cwd: APP_DIR, env }); const sig = readFileSync(`${file}.sig`, 'utf8').trim(); const v = verifySig(readFileSync(file), sig, confPubkey()); if (!v.ok) fail(`${basename(file)}: fresh signature does not verify (${v.reason})`); @@ -281,7 +285,8 @@ function writeVersionFiles(out: string, manifest: Manifest): void { function gitState(): { commit: string; dirty: boolean } { const commit = run(['git', 'rev-parse', 'HEAD'], { cwd: ROOT }).out.trim(); - const dirty = run(['git', 'status', '--porcelain'], { cwd: ROOT }).out.trim().length > 0; + // a fixed release tool (release --resume after a tool bug) is not part of what ships: the build stays clean + const dirty = run(['git', 'status', '--porcelain'], { cwd: ROOT }).out.split('\n').some(l => l.trim() && !/^ M tools\/(desktop-)?release(-candidate)?\.ts$/.test(l)); return { commit, dirty }; } diff --git a/tools/release-candidate.ts b/tools/release-candidate.ts index bb0ff92..bb4e8b3 100644 --- a/tools/release-candidate.ts +++ b/tools/release-candidate.ts @@ -95,8 +95,9 @@ export interface Task { id: number; name: string; run_number: number; workflow_i /** The state of a commit from its ci.yml jobs (the newest run of that commit) and whether its record exists. Pure. */ export function taskState(tasks: Task[], sha: string, runUrl: (n: number) => string): Exclude { // the background test jobs (ci, acceptance) never gate a release (the owner's decision 2026-10-16): only the - // installer builds and the record count; tools/ci-status.ts reports the tests - const mine = tasks.filter(t => t.head_sha === sha && t.workflow_id === 'ci.yml' && !/^(ci|acceptance\b)/.test(t.name)); + // installer builds and the record count; tools/ci-status.ts reports the tests. The dev job (the owner's dev update + // channel) never gates a release either: a hiccup publishing a dev build must not hold back npm. + const mine = tasks.filter(t => t.head_sha === sha && t.workflow_id === 'ci.yml' && !/^(ci|acceptance\b|dev$)/.test(t.name)); if (!mine.length) return { state: 'none' }; const latest = Math.max(...mine.map(t => t.run_number)); const jobs = mine.filter(t => t.run_number === latest); diff --git a/tools/release.ts b/tools/release.ts index 16e3ec7..016d91f 100644 --- a/tools/release.ts +++ b/tools/release.ts @@ -278,8 +278,8 @@ async function main(): Promise { const remote = remoteSessionReason(process.env, sh(['launchctl', 'managername'], { allowFail: true }).trim()); if (remote) fail(`${remote}: signing and notarization need the owner's logged-in macOS session`); if (!existsSync(join(KEYS, 'updater.key'))) fail(`no updater key in ${KEYS.replace(HOME, '~')}`); - // --resume may run with a fixed release tool (tools/release*.ts), which is not part of what ships - const dirty = sh(['git', 'status', '--porcelain']).split('\n').filter(l => l.trim() && !(resume && /^ M tools\/release(-candidate)?\.ts$/.test(l))); + // --resume may run with a fixed release tool (tools/release*.ts, tools/desktop-release.ts), which is not part of what ships + const dirty = sh(['git', 'status', '--porcelain']).split('\n').filter(l => l.trim() && !(resume && /^ M tools\/(desktop-)?release(-candidate)?\.ts$/.test(l))); if (dirty.length) fail('the checkout has uncommitted changes'); sh(['git', 'fetch', '--quiet', 'origin', 'main', '--tags']); const head = sh(['git', 'rev-parse', 'HEAD']).trim(); @@ -395,6 +395,13 @@ async function main(): Promise { // ---------------------------------------------------------------- 6. publish await phase('publish (VDS seeds from Forgejo, Mac files once, latest.json)', async () => { + // a resume after a publish that went out (only its check failed, e.g. the site was down) must not publish again: + // signatures are not byte-identical on a second run and the server never replaces a published file + if (!rehearsal && resume) { + const live = await fetch(`https://storebound.dev/desktop/latest.json?t=${Date.now()}`, { signal: AbortSignal.timeout(20_000) }) + .then(r => (r.ok ? r.json() as Promise<{ version?: string }> : null)).catch(() => null); + if (live?.version === version) { say(`desktop ${version} is already live on the stable channel: publish skipped`); return; } + } const code = await bg('publish', ['bun', 'tools/desktop-release.ts', 'publish', '--out', out, '--host', host, '--channel', rehearsal ? 'rehearsal' : 'stable', '--forgejo-tag', tag, '--yes'], join(logDir, 'publish.log'), { timeoutMs: 45 * 60_000 }); if (code !== 0) fail(`publish failed: ${join(logDir, 'publish.log').replace(HOME, '~')}`); }); diff --git a/tools/scrub.ts b/tools/scrub.ts index f84cf66..e0569a1 100644 --- a/tools/scrub.ts +++ b/tools/scrub.ts @@ -27,7 +27,7 @@ export const RULES: Rule[] = [ { id: 'private-name', re: new RegExp(['Yon', 'ga'].join(''), 'gi') }, { id: 'google-api-key', re: new RegExp('AI' + 'za[0-9A-Za-z_-]{35}', 'g') }, // absolute home folders leak a machine's account name (placeholders like /Users/you are fine) - { id: 'home-path', re: /(?:\/Users\/|\/home\/|[A-Z]:\\Users\\)(?!(?:you|me|user|runner|name|example)\b)[A-Za-z0-9._-]+/g }, + { id: 'home-path', re: /(?:\/Users\/|\/home\/|[A-Z]:\\Users\\)(?!(?:you|me|user|runner|name|example|Shared)\b)[A-Za-z0-9._-]+/g }, ]; const SKIP = /(^|\/)(bun\.lock|Cargo\.lock|LICENSE)$|\.(png|jpe?g|webp|gif|ico|icns|woff2?|ttf|otf|p8|p12|aab|ipa|zip)$/i;