diff --git a/.env.example b/.env.example index e6aab76..c42f3db 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,10 @@ ANDROID_HOME= ANDROID_SDK_ROOT= MOONLIT_PYTHON= +# Apple Developer Team ID that `pnpm ios:*` signs with. Empty keeps the team +# that publishes Moonlit Beacon; set your own to build a copy of the game. +MOONLIT_APPLE_TEAM_ID= + # Values that go out on the store listing as-is. MOONLIT_SUPPORT_EMAIL= MOONLIT_PUBLIC_SITE_URL= diff --git a/.github/scripts/check-hygiene.mjs b/.github/scripts/check-hygiene.mjs index d2dda1c..96f01d4 100644 --- a/.github/scripts/check-hygiene.mjs +++ b/.github/scripts/check-hygiene.mjs @@ -57,6 +57,7 @@ const FORBIDDEN = [ [/\.godot\//, '.godot/ is committed. It contains the signing keystore password'], [/\.zip$/, 'an original asset ZIP is committed'], [/(^|\/)_tmp_/, 'a render temp file (_tmp_*) is committed'], + [/(^|\/)iapkit(_publishable)?\.cfg$/, 'an IAPKit key file is committed; exports generate it'], ]; for (const file of tracked) { for (const [pattern, why] of FORBIDDEN) { diff --git a/.github/scripts/check-locale.mjs b/.github/scripts/check-locale.mjs index 846cedd..429f098 100644 --- a/.github/scripts/check-locale.mjs +++ b/.github/scripts/check-locale.mjs @@ -65,9 +65,13 @@ const KEY = /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g; // Looks like a key but is not. Engine constants and node names. const NOT_KEYS = /^(?:[A-Z]+_[A-Z0-9_]*(?:MODE|FILTER|PRESET|DIRECTION|ALIGNMENT|MASK|LAYER)|SUB_RESOURCE|EXT_RESOURCE|GD_SCENE|PACKED[A-Z0-9]*ARRAY|STYLE_BOX[A-Z_]*|NODE_PATH)$/; +// The vendored godot-iap addon never uses the game's keys; its env-var names look like keys. +const VENDORED_IAP = join(GAME, 'addons', 'godot-iap'); + const walk = (dir) => readdirSync(dir).flatMap((name) => { if (name === '.godot' || name === 'assets') return []; const full = join(dir, name); + if (full === VENDORED_IAP) return []; return statSync(full).isDirectory() ? walk(full) : [full]; }); diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 1f3b3af..e766faa 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -178,7 +178,7 @@ jobs: echo "::error::Direct-distribution APK contains tests or production tools" exit 1 fi - if grep -Eq '(^|/)iapkit\.cfg$' <<<"$APK_ENTRIES"; then + if grep -Eq '(^|/)iapkit_publishable\.cfg$' <<<"$APK_ENTRIES"; then echo "::error::Direct-distribution APK contains IAPKit config" exit 1 fi @@ -216,12 +216,12 @@ jobs: echo "::error::Play AAB contains tests or production tools" exit 1 fi - IAPKIT_CFG_COUNT=$(grep -Ec '(^|/)iapkit\.cfg$' <<<"$AAB_ENTRIES") + IAPKIT_CFG_COUNT=$(grep -Ec '(^|/)iapkit_publishable\.cfg$' <<<"$AAB_ENTRIES") if [ "$IAPKIT_CFG_COUNT" -ne 1 ]; then echo "::error::Play AAB IAPKit config count is not 1" exit 1 fi - IAPKIT_CFG=$(unzip -p "$AAB" '*/assets/iapkit.cfg') + IAPKIT_CFG=$(unzip -p "$AAB" '*/assets/iapkit_publishable.cfg') if ! grep -Eq '^api_key="openiap-kit_pk_[A-Za-z0-9_-]{32,}"$' \ <<<"$IAPKIT_CFG"; then echo "::error::Play AAB IAPKit publishable config format is invalid" @@ -231,7 +231,7 @@ jobs: echo "::error::Play AAB contains an IAPKit secret/admin key" exit 1 fi - if [ -e apps/game/android/build/assetPackInstallTime/src/main/assets/iapkit.cfg ]; then + if [ -e apps/game/android/build/assetPackInstallTime/src/main/assets/iapkit_publishable.cfg ]; then echo "::error::IAPKit config remained in the Gradle template after export" exit 1 fi diff --git a/.gitignore b/.gitignore index 0676d43..39298e2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ builds/ # copies game assets into them, so they are not committed. apps/game/android/ # IAPKit publishable config created only during store export. Not a lasting source. +apps/game/iapkit_publishable.cfg +# godot-iap's own local settings file (dropped from release exports). Never commit it. apps/game/iapkit.cfg # Do not add a global `*.zip` rule. # Original asset zips are already excluded via _downloads/ and diff --git a/AGENTS.md b/AGENTS.md index b2b394a..2c377f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,10 +168,10 @@ included. Changing one GDScript doc comment changes `runtime_sha256` and invalidates the phone, 7-inch, 10-inch, and iPad sets, even if the pixels did not move. -**`export_presets.cfg` and `iapkit.cfg` are excluded.** Bumping version and -build numbers does not break the fingerprint. That is a fingerprint fact; -the recapture rule above ("lock the version before capture") is separate -and still applies. +**`export_presets.cfg` and `iapkit_publishable.cfg` are excluded.** Bumping +version and build numbers does not break the fingerprint. That is a +fingerprint fact; the recapture rule above ("lock the version before +capture") is separate and still applies. **If you touched `apps/game/`, run this separately:** @@ -562,6 +562,7 @@ tool environments. Only `ios:upload` actually sends a binary; `pnpm android:bundle` and iOS export need an IAPKit publishable key for purchase verification. They look at `IAPKIT_API_KEY` first, and on macOS fall back to the Keychain service `dev.openiap.kit.moonlitbeacon`, account -`MoonlitBeacon Mobile`. The generated `apps/game/iapkit.cfg` is deleted -after export and is not in Git. Never put an `openiap-kit_sk_…` -secret/admin key in an app build. +`MoonlitBeacon Mobile`. The generated `apps/game/iapkit_publishable.cfg` is +deleted after export and is not in Git. It is not named `iapkit.cfg` +because godot-iap 3.6 leaves that file out of release exports. Never put an +`openiap-kit_sk_…` secret/admin key in an app build. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70a6bc6..303d198 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,8 @@ pnpm verify # same checks CI runs Store signing keys, IAPKit secrets, and App Store Connect keys stay on the machine. Copy `.env.example` to `.env` and fill only what you need. Never -commit `.env`, `.godot/`, `*.jks`, `*.p8`, or `iapkit.cfg`. +commit `.env`, `.godot/`, `*.jks`, `*.p8`, `iapkit_publishable.cfg`, or +`iapkit.cfg`. Fork pull requests build the debug APK only. GitHub withholds secrets from forks, so the store AAB step (it needs `IAPKIT_API_KEY`) and its checks are @@ -38,7 +39,7 @@ skipped there — that skip is expected, not a failure. ## godot-iap -The plugin is vendored from the official `godot-iap-3.5.1` zip with a small +The plugin is vendored from the official `godot-iap-3.6.1` zip with a small project patch documented in `vendor/godot-iap/README.md`. `pnpm test:godot-iap-vendor` checks that the patch reverses to the official bytes. diff --git a/LICENSE b/LICENSE index 33891ce..9048d52 100644 --- a/LICENSE +++ b/LICENSE @@ -19,9 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---- -Third-party assets in this repository (fonts, art, audio — see -`apps/docs/docs/assets/third-party.md`) keep their own licenses. In -particular the Nexon MapleStory font allows commercial use and embedding -but forbids sale and modification. diff --git a/README.md b/README.md index d454a3f..3913acf 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ Docs site: **https://hyodotdev.github.io/MoonlitBeacon/** | Stretch | `canvas_items` + `expand`, nearest-neighbor filter | | Reference device | Pixel 10 — 2424 × 1080 landscape, arm64-v8a | | Controls | Full-screen floating move stick, auto-attack, dash button | -| IAP | **godot-iap 3.5.1** + IAPKit (publishable key at export time) | +| IAP | **godot-iap 3.6.1** + IAPKit (publishable key at export time) | | Application id | `com.crossplatformkorea.moonlitbeacon` | | Version | **2.1.0** (both stores live) | -| License | MIT code; third-party credits below | +| License | MIT code; third-party assets keep their own ([notices](THIRD_PARTY_NOTICES.md)) | 808 × 360 is exactly one third of the Pixel 10 landscape panel, so 16 px pixel art scales by an integer 3×. Aspect is `expand`, not `keep`, so extra width @@ -45,23 +45,59 @@ Windows / Web are out of the current ship scope. Desktop runs are for development. `pointing/emulate_touch_from_mouse=true` lets the virtual stick work with a mouse. -## godot-iap example - -Store purchases go through the official OpenIAP Godot addon, not a bespoke -billing bridge. - -- Plugin: `apps/game/addons/godot-iap/` from - [`godot-iap-3.5.1`](https://github.com/hyodotdev/openiap/releases/tag/godot-iap-3.5.1) -- Game adapter: `apps/game/scripts/iap/` -- Server verification: IAPKit (`openiap-kit_pk_…` publishable key only; - admin/secret keys never enter the app) -- Android remote dependency: `io.github.hyochan.openiap:openiap-google:3.5.2` -- Direct-distribution APKs (itch.io) skip the native store singleton via - `OS.has_feature("direct_distribution")` - -The project patch on top of the official zip is small and documented in -[`vendor/godot-iap/README.md`](vendor/godot-iap/README.md). -`pnpm test:godot-iap-vendor` checks that it reverses to the official bytes. +## Using this as a reference + +Moonlit Beacon is a shipping example of +[godot-iap](https://openiap.dev/docs/setup/godot) **3.6.1** with +[IAPKit](https://kit.openiap.dev) purchase verification. It sells one-time +products and consumables on the App Store and Google Play. There are no +subscriptions. + +What the code shows: + +- Fetch products, purchase, verify the receipt with IAPKit, then finish the + transaction (permanent unlocks) or consume it (continue coins) only after + the entitlement is saved. +- Restore purchases, re-sync when the app resumes, and take back only a + purchase the store and IAPKit report as refunded or revoked. +- Read purchases with `get_available_purchases_result()`, so a failed store + query is never mistaken for "no purchases". + +Start with `apps/game/scripts/iap/iap_store.gd` (the `Shop` autoload: catalog, +ledger, restore, refunds) and `godot_iap_backend.gd`, the only file that calls +godot-iap. + +To run purchases in your own copy, change: + +1. **Application id.** `com.crossplatformkorea.moonlitbeacon` is this game's + store identity. Replace it everywhere (`git grep` finds each place); + product IDs are built from `APP_ID` in `iap_store.gd`. +2. **Signing and team.** Store builds need your own Android upload keystore + (`GODOT_ANDROID_KEYSTORE_RELEASE_*` in [`.env.example`](.env.example)). + iOS builds sign with `MOONLIT_APPLE_TEAM_ID`, which defaults to this + project's team. +3. **Store products.** Create the SKUs listed in + [Monetize](apps/docs/docs/monetize.md) in App Store Connect and Play + Console, or change the catalog in `iap_store.gd`. +4. **IAPKit key.** Set `IAPKIT_API_KEY` to your project's `openiap-kit_pk_…` + publishable key; never ship an `openiap-kit_sk_…` key. Store exports write + it to `apps/game/iapkit_publishable.cfg` and delete the file afterwards. + Do not use `res://iapkit.cfg`: godot-iap 3.6 leaves it out of release + exports. + +How the pieces fit: + +- `apps/game/addons/godot-iap/` is the official 3.6.1 addon plus two small + patches, documented in [`vendor/godot-iap/README.md`](vendor/godot-iap/README.md). + `pnpm test:godot-iap-vendor` checks them against the release. +- The iOS frameworks live in `vendor/godot-iap-ios/bin/`, outside `res://`, + because desktop Godot 4.7 logs errors for an iOS-only extension. Every + `pnpm ios:*` export copies them into the addon, puts the descriptor in the + PCK, runs `fix_ios_embed.sh` to embed the frameworks in Xcode, and removes + the copy again. +- The direct-distribution APK (itch.io) has no store SDK: `pnpm android:build` + moves the Android plugin aside, and the `direct_distribution` feature keeps + the shop off. See also [Monetize](apps/docs/docs/monetize.md) and [IAP store setup](notes/release/iap-store-setup.md). @@ -211,11 +247,13 @@ Font Noto Sans CJK SC — Google — SIL Open Font License 1.1 Store SDK - godot-iap 3.5.1 — OpenIAP contributors — MIT License + godot-iap 3.6.1 — OpenIAP contributors — MIT License Made by Hyo Dev ``` -See [third-party assets](apps/docs/docs/assets/third-party.md). -Game code is [MIT](LICENSE). Contributions: [CONTRIBUTING.md](CONTRIBUTING.md). +Game code is [MIT](LICENSE). Third-party assets keep their own licenses; see +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and the +[third-party assets](apps/docs/docs/assets/third-party.md) list. +Contributions: [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..afbe0c4 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,12 @@ +# Third-party notices + +The game code is MIT licensed ([LICENSE](LICENSE)). Third-party assets in this +repository (fonts, art, audio) keep their own licenses. Each one is listed in +[`apps/docs/docs/assets/third-party.md`](apps/docs/docs/assets/third-party.md), +and license texts live in [`apps/game/docs/licenses/`](apps/game/docs/licenses/). + +In particular, the Nexon MapleStory font allows commercial use and embedding +but forbids sale and modification. + +The vendored godot-iap addon is MIT licensed; its license is kept at +[`apps/game/addons/godot-iap/LICENSE`](apps/game/addons/godot-iap/LICENSE). diff --git a/apps/docs/docs/assets/third-party.md b/apps/docs/docs/assets/third-party.md index 9533867..8524edf 100644 --- a/apps/docs/docs/assets/third-party.md +++ b/apps/docs/docs/assets/third-party.md @@ -21,18 +21,18 @@ the full license text or a link. | Item | Detail | | --- | --- | -| Name | godot-iap 3.5.1 | +| Name | godot-iap 3.6.1 | | Author | OpenIAP contributors | | License | MIT License | -| Source | https://github.com/hyodotdev/openiap/releases/tag/godot-iap-3.5.1 | +| Source | https://github.com/hyodotdev/openiap/releases/tag/godot-iap-3.6.1 | | Scope | Product query, purchase, and restore for Android Play Billing and iOS StoreKit 2 | -| Status | ✅ in use (7 non-consumable items in the Moonlit Store) | +| Status | ✅ in use (10 items in the Moonlit Store: 7 non-consumable, 3 consumable) | The plugin's original license is kept with `apps/game/addons/godot-iap/LICENSE` and the iOS distribution `vendor/godot-iap-ios/LICENSE`. It is not an external **art asset**, so it is not in the asset manifest. -The Android AAR is taken byte-for-byte from the official 3.5.1 release. +The Android AAR is taken byte-for-byte from the official 3.6.1 release. Provenance, hashes, and the small GDScript integration patch are recorded in `vendor/godot-iap-android/README.md` and `vendor/godot-iap/README.md`. @@ -119,7 +119,7 @@ Font SIL Open Font License 1.1 Store integration - godot-iap 3.5.1 + godot-iap 3.6.1 OpenIAP contributors · MIT License Made by diff --git a/apps/docs/docs/monetize.md b/apps/docs/docs/monetize.md index 78a0ea4..fb683af 100644 --- a/apps/docs/docs/monetize.md +++ b/apps/docs/docs/monetize.md @@ -24,8 +24,8 @@ business information with it. If you do not make that decision first, everything else is wasted work. The direct-distribution APK and the itch.io build still ship without -payments. Store-build shop code is implemented, but live sales stay off -until each platform's product listing and review are done. +payments. The Google Play and App Store builds sell the items in +[section 8](#8-so-what-does-this-game-sell). ## 2. In an endless roguelike, only three things can be sold @@ -168,14 +168,14 @@ Godot has no official IAP support. This course uses **[godot-iap][iap]** — a cross-platform plugin that follows the OpenIAP spec and covers iOS StoreKit 2 and Android Play Billing with one API. MIT. -[iap]: https://github.com/hyodotdev/openiap/releases/tag/godot-iap-3.5.1 +[iap]: https://github.com/hyodotdev/openiap/releases/tag/godot-iap-3.6.1 | | | | --- | --- | | Requires | Godot 4.3+ · iOS 17+ · Android API 24+ | -| Version | godot-iap 3.5.1 | +| Version | godot-iap 3.6.1 | | Repo | `libraries/godot-iap` in `hyodotdev/openiap` | -| Docs | `openiap.dev` — `llms-full.txt` can fetch the whole API at once | +| Docs | [Godot setup](https://openiap.dev/docs/setup/godot) · `llms-full.txt` fetches the whole API at once | Moonlit Beacon is Godot 4.7.1, so it meets the requirement. @@ -207,6 +207,9 @@ GodotIapPlugin.request_purchase(props) # Always finish after the grant await GodotIapPlugin.finish_transaction(purchase, is_consumable) + +# Restore and re-sync: the result form keeps a failed query apart from "none" +var owned: Dictionary = await GodotIapPlugin.get_available_purchases_result() ``` :::danger Do not skip `finish_transaction()` @@ -308,7 +311,7 @@ Continues exist, but **there is no urgency-payment timer, energy, or gacha.** Coins are bought ahead in the shop after checking quantity and price, and unused coins stay. -## 9. Implementation status and pre-ship blockers +## 9. Implementation status and store-side steps Shop UI, IAPKit server verification, permanent entitlement storage, duplicate-transaction prevention, purchase restore, and hero-origin @@ -326,7 +329,9 @@ missing list does not revoke an already saved entitlement. On a desktop run you launched yourself, and on the itch.io APK, the shop not opening is expected. -The following, though, are **external blockers** code cannot stand in for. +Both store listings sell the 10 items today. The steps below are the +store-side work code cannot stand in for; a copy of this project has to do +them again with its own accounts. 1. The actual selling party must enter and get approved developer accounts, contracts, tax, and payout information in App Store Connect @@ -352,5 +357,5 @@ The following, though, are **external blockers** code cannot stand in for. Follow `notes/release/iap-store-setup.md` in the repo for the actual console input and test order. -Until this checklist is finished, keep direct-distribution APK and -itch.io copy at "no payments." +The direct-distribution APK and itch.io copy stay at "no payments" either +way: itch.io cannot sell in-app purchases. diff --git a/apps/game/addons/godot-iap/android/GodotIap.gdap b/apps/game/addons/godot-iap/android/GodotIap.gdap index 1cebd20..d1b7f53 100644 --- a/apps/game/addons/godot-iap/android/GodotIap.gdap +++ b/apps/game/addons/godot-iap/android/GodotIap.gdap @@ -5,4 +5,4 @@ binary="GodotIap.release.aar" [dependencies] local=[] -remote=["io.github.hyochan.openiap:openiap-google:3.5.2", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0"] +remote=["io.github.hyochan.openiap:openiap-google:3.6.1", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0"] diff --git a/apps/game/addons/godot-iap/android_store.gd b/apps/game/addons/godot-iap/android_store.gd new file mode 100644 index 0000000..b4377d9 --- /dev/null +++ b/apps/game/addons/godot-iap/android_store.gd @@ -0,0 +1,115 @@ +## Android store selection for exports, by the same rules as +## packages/google/gradle/openiap-store.gradle. +extends RefCounted + +const STORES: PackedStringArray = ["auto", "play", "horizon", "amazon"] +const ALIASES := { + "auto": "auto", + "play": "play", + "google": "play", + "gplay": "play", + "googleplay": "play", + "google-play": "play", + "gms": "play", + "horizon": "horizon", + "meta": "horizon", + "quest": "horizon", + "amazon": "amazon", + "fire": "amazon", + "fireos": "amazon", + "fire-os": "amazon", +} + + +## Returns the store id, or "" when the value names no store. +static func normalize(value: Variant) -> String: + if value == null: + return "auto" + var key := str(value).strip_edges().to_lower() + if key.is_empty(): + return "auto" + return ALIASES.get(key, "") + + +const HORIZON_APP_ID_META_DATA := "com.meta.horizon.platform.HORIZON_APP_ID" + +## Export feature tag for the store an Android export linked, which get_store() +## reads at runtime. Play is the untagged default. +static func store_feature(store: String) -> String: + return "openiap_store_" + store if store in ["horizon", "amazon"] else "" + + +## The manifest entry the Horizon SDK reads the app id from, or "" unless the +## value is the numeric id from Meta Horizon Developer Hub. +static func horizon_app_id_meta_data(app_id: Variant) -> String: + var id := "" if app_id == null else str(app_id).strip_edges() + if RegEx.create_from_string("^[0-9]+$").search(id) == null: + return "" + return '' % [HORIZON_APP_ID_META_DATA, id] + + +## Rewrites the openiap-google coordinate for the store; other coordinates pass through. +static func artifact(coordinate: String, store: String) -> String: + var resolved := "play" if store == "auto" else store + var suffix := "" if resolved == "play" else "-" + resolved + return coordinate.replace(":openiap-google:", ":openiap-google" + suffix + ":") + + +## The serial `adb devices` selects: ANDROID_SERIAL when it is attached, +## otherwise the only device. "" when that is absent or ambiguous. +static func pick_serial(devices_output: String, requested: String) -> String: + var row := RegEx.create_from_string("^(\\S+)\\s+device$") + var serials := PackedStringArray() + for line in devices_output.split("\n"): + # Windows adb ends lines with CR, which the serial would otherwise keep. + var found := row.search(line.replace("\r", "").strip_edges()) + if found: + serials.append(found.get_string(1)) + var wanted := requested.strip_edges() + if not wanted.is_empty(): + return wanted if serials.has(wanted) else "" + return serials[0] if serials.size() == 1 else "" + + +## The store a device's feature list and manufacturer point to. +static func classify(features: String, manufacturer: String) -> String: + if features.contains("feature:horizonos.software.horizon_os") or features.contains("feature:oculus.hardware.standalone_vr"): + return "horizon" + if features.contains("feature:amazon.hardware.fire_tv") or manufacturer.strip_edges().to_lower() == "amazon": + return "amazon" + return "play" + + +## Resolves auto: a debug export follows the one connected device, and a +## release export never looks, so its SDK cannot depend on what is plugged in. +static func resolve_auto(debug: bool, adb: String) -> Dictionary: + if not debug: + return {"store": "play", "source": "default", "reason": "release export"} + var listing := _adb_text(adb, ["devices"]) + # adb always prints a header, so no output means adb itself failed. + if listing.is_empty(): + return {"store": "play", "source": "default", "reason": "adb did not answer"} + var requested := OS.get_environment("ANDROID_SERIAL").strip_edges() + var serial := pick_serial(listing, requested) + if serial.is_empty(): + if not requested.is_empty(): + push_warning("[GodotIap] ANDROID_SERIAL=%s is not attached; not selecting a store from a device" % requested) + return {"store": "play", "source": "default", "reason": "no single connected device"} + var features := _adb_text(adb, ["-s", serial, "shell", "pm", "list", "features"]) + var manufacturer := _adb_text(adb, ["-s", serial, "shell", "getprop", "ro.product.manufacturer"]).strip_edges() + # A device that dropped after the listing answers with nothing, which is + # not a signal that it meant Play. + if features.strip_edges().is_empty() and manufacturer.is_empty(): + return {"store": "play", "source": "default", "reason": "%s stopped responding" % serial} + return { + "store": classify(features, manufacturer), + "source": "device", + "reason": "device %s, manufacturer %s" % [serial, manufacturer if not manufacturer.is_empty() else "unknown"], + } + + +static func _adb_text(adb: String, args: PackedStringArray) -> String: + var output := [] + if OS.execute(adb, args, output) != 0 or output.is_empty(): + return "" + return str(output[0]) diff --git a/apps/game/addons/godot-iap/android_store.gd.uid b/apps/game/addons/godot-iap/android_store.gd.uid new file mode 100644 index 0000000..bb6df60 --- /dev/null +++ b/apps/game/addons/godot-iap/android_store.gd.uid @@ -0,0 +1 @@ +uid://b1lcs5a8hngpr diff --git a/apps/game/addons/godot-iap/godot_iap.gd b/apps/game/addons/godot-iap/godot_iap.gd index 91c1b09..521e5d4 100644 --- a/apps/game/addons/godot-iap/godot_iap.gd +++ b/apps/game/addons/godot-iap/godot_iap.gd @@ -11,6 +11,7 @@ class_name GodotIapWrapper # Types from OpenIAP spec const Types = preload("types.gd") +const AndroidStore = preload("android_store.gd") const APPLE_PLATFORMS := ["iOS", "macOS"] const APPLE_ASYNC_RESULT_CACHE_LIMIT := 64 @@ -72,15 +73,15 @@ var _apple_async_terminal_keys: Dictionary = {} var _apple_async_terminal_order: Array[String] = [] var _apple_async_cancellation_generation := 0 var _apple_async_timeout_seconds := 30.0 -# AppStore.sync() can present Face ID, password, or account sheets. -# Failing an explicit restore at 120s while native auth is still running -# leaves the app UI failed and the system prompt alive. Moonlit Beacon -# keeps the verified 10-minute restore limit. +# Moonlit: AppStore.sync() can show Face ID or sign-in sheets that outlast +# 120 s, so an explicit restore waits up to 10 minutes. var _apple_async_restore_timeout_seconds := 600.0 var _apple_async_ui_timeout_seconds := 300.0 # Platform detection var _platform: String = "" +## OS.has_feature, swappable in tests: an editor run carries no export tags. +var _has_feature: Callable = Callable(OS, "has_feature") func _is_apple() -> bool: @@ -88,10 +89,6 @@ func _is_apple() -> bool: func _ready() -> void: - # Direct-distribution APKs (itch.io) ship without a native store SDK. - # Keep the same autoload, but skip singleton lookup and error logs. - if OS.has_feature("direct_distribution"): - return if _is_initialized: return _is_initialized = true @@ -1600,7 +1597,7 @@ func get_promoted_product_ios() -> Variant: return Types.ProductIOS.from_dict(parsed) return null -## Check if can present external purchase notice (iOS 18.2+). +## Check if can present external purchase notice (iOS 17.4+). ## @return bool - true if external purchase notice can be presented ## ## See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios @@ -1610,7 +1607,7 @@ func can_present_external_purchase_notice_ios() -> bool: return payload.get("success", false) and payload.get("canPresent", false) return false -## Present external purchase notice sheet (iOS 18.2+). +## Present external purchase notice sheet (iOS 17.4+). ## @return Types.ExternalPurchaseNoticeResultIOS ## ## See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios @@ -1626,7 +1623,7 @@ func present_external_purchase_notice_sheet_ios() -> Variant: var default_result = Types.ExternalPurchaseNoticeResultIOS.new() return default_result -## Present external purchase link (iOS 18.2+). +## Present external purchase link. ## @param url: String - external purchase URL ## @return Types.ExternalPurchaseLinkResultIOS ## @@ -2168,6 +2165,11 @@ func is_stub_mode() -> bool: ## Returns Types.IapStore enum value func get_store() -> Variant: if _platform == "Android": + # Every store is an Android build; the export tags the one it linked. + if _has_feature.call(AndroidStore.store_feature("horizon")): + return Types.IapStore.HORIZON + if _has_feature.call(AndroidStore.store_feature("amazon")): + return Types.IapStore.AMAZON return Types.IapStore.GOOGLE elif _is_apple(): return Types.IapStore.APPLE diff --git a/apps/game/addons/godot-iap/godot_iap_plugin.gd b/apps/game/addons/godot-iap/godot_iap_plugin.gd index d8fb762..9e06100 100644 --- a/apps/game/addons/godot-iap/godot_iap_plugin.gd +++ b/apps/game/addons/godot-iap/godot_iap_plugin.gd @@ -30,6 +30,12 @@ func _exit_tree() -> void: class GodotIapExportPlugin extends EditorExportPlugin: const PLUGIN_NAME = "GodotIap" const ANDROID_GDAP_PATH = "res://addons/godot-iap/android/GodotIap.gdap" + # Untracked developer settings. The example includes it so a debug export can + # reach a local IAPKit server; a release export must never carry the key. + const LOCAL_SETTINGS_PATH = "res://iapkit.cfg" + const AndroidStore = preload("res://addons/godot-iap/android_store.gd") + const ANDROID_STORE_OPTION = "openiap/android_store" + const HORIZON_APP_ID_OPTION = "openiap/horizon_app_id" const IOS_FRAMEWORKS: Array[String] = [ "res://addons/godot-iap/bin/ios/GodotIap.framework", "res://addons/godot-iap/bin/ios/SwiftGodotRuntime.framework", @@ -49,14 +55,16 @@ class GodotIapExportPlugin extends EditorExportPlugin: return true return false - func _export_begin(features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void: + var _debug_export := false + + func _export_begin(features: PackedStringArray, is_debug: bool, _path: String, _flags: int) -> void: + _debug_export = is_debug + if not _is_ios_export(features): return - # Keeping the iOS-only descriptor under the live `.gdextension` name - # makes the desktop editor hunt for a host-platform library and ERROR. - # Export still embeds the original name plus the extension list, so iOS - # runtime behavior stays the same and day-to-day editing stays quiet. + # Moonlit keeps the iOS-only descriptor as .gdextension.ios so desktop + # Godot 4.7 logs no errors; the PCK still gets it under the live name. if FileAccess.file_exists(IOS_GDEXTENSION_SOURCE): add_file( IOS_GDEXTENSION_EXPORT, @@ -77,6 +85,11 @@ class GodotIapExportPlugin extends EditorExportPlugin: continue _add_ios_embedded_framework(framework_path) + func _export_file(path: String, _type: String, _features: PackedStringArray) -> void: + if path == LOCAL_SETTINGS_PATH and not _debug_export: + push_warning("[GodotIap] Release export: leaving %s out of the bundle" % path) + skip() + func _is_ios_export(features: PackedStringArray) -> bool: var platform = get_export_platform() return ( @@ -103,8 +116,92 @@ class GodotIapExportPlugin extends EditorExportPlugin: else: return PackedStringArray(["res://addons/godot-iap/android/GodotIap.release.aar"]) + func _get_export_options(platform: EditorExportPlatform) -> Array[Dictionary]: + if not (platform is EditorExportPlatformAndroid): + return [] + return [{ + "option": { + "name": ANDROID_STORE_OPTION, + "type": TYPE_STRING, + "hint": PROPERTY_HINT_ENUM, + "hint_string": ",".join(AndroidStore.STORES), + }, + "default_value": "auto", + }, { + "option": { + "name": HORIZON_APP_ID_OPTION, + "type": TYPE_STRING, + }, + "default_value": "", + }] + + func _get_android_manifest_application_element_contents(_platform: EditorExportPlatform, _debug: bool) -> String: + var value = get_option(HORIZON_APP_ID_OPTION) + var app_id := "" if value == null else str(value).strip_edges() + if app_id.is_empty(): + return "" + var meta_data := AndroidStore.horizon_app_id_meta_data(app_id) + if meta_data.is_empty(): + push_error("[GodotIap] %s must be the numeric app id from Meta Horizon Developer Hub" % HORIZON_APP_ID_OPTION) + return meta_data + + # One answer per export: the dependencies and the feature tag must agree, and + # the device is probed once. + var _android_stores := {} + + func _export_end() -> void: + _android_stores.clear() + + ## The store this Android export links, or "" when the option names none. + func _android_store(debug: bool) -> String: + var option = get_option(ANDROID_STORE_OPTION) + var key := "%s|%s" % [debug, option] + if _android_stores.has(key): + return _android_stores[key] + var store := AndroidStore.normalize(option) + if store == "auto": + var resolution := AndroidStore.resolve_auto(debug, _adb_path() if debug else "") + store = resolution.store + print("[GodotIap] openiap: store=%s (source=%s; %s)" % [resolution.store, resolution.source, resolution.reason]) + elif not store.is_empty(): + print("[GodotIap] openiap: store=%s (source=explicit; %s)" % [store, ANDROID_STORE_OPTION]) + _android_stores[key] = store + return store + + func _get_export_features(platform: EditorExportPlatform, debug: bool) -> PackedStringArray: + if not (platform is EditorExportPlatformAndroid): + return PackedStringArray() + var feature := AndroidStore.store_feature(_android_store(debug)) + return PackedStringArray([feature]) if not feature.is_empty() else PackedStringArray() + func _get_android_dependencies(platform: EditorExportPlatform, debug: bool) -> PackedStringArray: - return _read_android_remote_dependencies() + var store := _android_store(debug) + if store.is_empty(): + # Godot's export API cannot abort here, so fall back to Play (the + # untagged default) instead of shipping the AAR without OpenIAP classes. + push_error("[GodotIap] %s must be one of: %s; falling back to Play" % [ANDROID_STORE_OPTION, ", ".join(AndroidStore.STORES)]) + store = "play" + var dependencies := PackedStringArray() + for dependency in _read_android_remote_dependencies(): + dependencies.append(AndroidStore.artifact(dependency, store)) + return dependencies + + # The editor's SDK setting first, then the Gradle resolver's fallbacks. + func _adb_path() -> String: + var roots := PackedStringArray() + var settings := EditorInterface.get_editor_settings() + if settings.has_setting("export/android/android_sdk_path"): + roots.append(str(settings.get_setting("export/android/android_sdk_path"))) + roots.append(OS.get_environment("ANDROID_HOME")) + roots.append(OS.get_environment("ANDROID_SDK_ROOT")) + for root in roots: + if root.strip_edges().is_empty(): + continue + for name in ["adb", "adb.exe"]: + var candidate := root.path_join("platform-tools").path_join(name) + if FileAccess.file_exists(candidate): + return candidate + return "adb" func _read_android_remote_dependencies() -> PackedStringArray: if not FileAccess.file_exists(ANDROID_GDAP_PATH): diff --git a/apps/game/addons/godot-iap/plugin.cfg b/apps/game/addons/godot-iap/plugin.cfg index 4302d4c..a36b33b 100644 --- a/apps/game/addons/godot-iap/plugin.cfg +++ b/apps/game/addons/godot-iap/plugin.cfg @@ -3,5 +3,5 @@ name="GodotIap" description="Cross-platform in-app purchase plugin for Godot using OpenIAP" author="hyochan" -version="3.5.1" +version="3.6.1" script="godot_iap_plugin.gd" diff --git a/apps/game/addons/godot-iap/scripts/fix_ios_embed.sh b/apps/game/addons/godot-iap/scripts/fix_ios_embed.sh index ef234a2..19357a5 100755 --- a/apps/game/addons/godot-iap/scripts/fix_ios_embed.sh +++ b/apps/game/addons/godot-iap/scripts/fix_ios_embed.sh @@ -77,10 +77,11 @@ copy_framework_plists # Backup original once per run. cp "$PBXPROJ" "$PBXPROJ.backup" -# Homebrew framework Python under Node spawnSync can stall loading -# extension modules on some macOS setups. The Xcode-bundled system -# Python has the stdlib this script needs, so prefer it. -PYTHON_BIN="${PYTHON_BIN:-}" +# Homebrew's framework Python can stall loading extension modules when this +# script is spawned from a build tool, so prefer the Xcode-bundled interpreter +# on macOS; this script only needs the standard library either way. +PYTHON_REQUEST="${PYTHON_BIN:-}" +PYTHON_BIN="$PYTHON_REQUEST" if [ -z "$PYTHON_BIN" ]; then if [ "$(uname -s)" = "Darwin" ] && [ -x /usr/bin/python3 ]; then PYTHON_BIN=/usr/bin/python3 @@ -88,8 +89,21 @@ if [ -z "$PYTHON_BIN" ]; then PYTHON_BIN="$(command -v python3 || true)" fi fi -if [ -z "$PYTHON_BIN" ] || [ ! -x "$PYTHON_BIN" ]; then - echo "Error: python3 is required to fix iOS framework embedding." >&2 +# -x does not search PATH, so resolve a bare name such as PYTHON_BIN=python3.13. +case "$PYTHON_BIN" in + "" | */*) ;; + *) PYTHON_BIN="$(command -v "$PYTHON_BIN" || true)" ;; +esac +# Ask it what it is, rather than trusting that it is executable: any command +# that swallows a heredoc would otherwise exit 0 having patched nothing, and +# one that ignores the probe and succeeds anyway still prints no version. +PYTHON_MAJOR="$("$PYTHON_BIN" -c 'import sys; print(sys.version_info[0])' 2>/dev/null || true)" +if [ "$PYTHON_MAJOR" != "3" ]; then + if [ -n "$PYTHON_REQUEST" ]; then + echo "Error: PYTHON_BIN=$PYTHON_REQUEST is not a Python 3 interpreter." >&2 + else + echo "Error: python3 is required to fix iOS framework embedding." >&2 + fi exit 1 fi @@ -162,6 +176,8 @@ for framework in frameworks: missing = [framework for framework in frameworks if framework not in framework_refs] if missing: + # Nothing can be embedded without these, so fail rather than hand a build + # script a success it would act on. The project file is left untouched. print( "Error: framework references not found in Xcode project: " + ", ".join(f"{framework}.framework" for framework in missing), @@ -412,7 +428,28 @@ def drop_conflicting_runtime_embed(text): if rival: removed.append((build_id, path, rival)) + # An xcframework whose manifest we cannot read may be a rival we never saw, + # so it is reported on both paths rather than passing as a clean result. + # Full paths, not basenames: two addons can ship the same xcframework name, + # and the reader has to know which copy to open. + unreadable = sorted({ + path + for _, path, stem in embedded + if stem is None and path.lower().endswith(".xcframework") + }) + if unreadable: + print( + f"Runtime embed check: could not read {', '.join(unreadable)}, so it " + f"was not checked for a duplicate. If Xcode lists the same framework " + f"twice under Embed Frameworks, delete one copy." + ) + if not removed: + # Ours are always here and always resolve: the run exits earlier when the + # project has no GodotIap reference, so this list is never empty. + seen = sorted(stem for _, _, stem in embedded if stem is not None) + print("Runtime embed check: no conflict among the embeds we could read: " + + ", ".join(seen)) return text updated_phase_block = phase_block @@ -421,8 +458,8 @@ def drop_conflicting_runtime_embed(text): text = text.replace(phase_block, updated_phase_block, 1) for build_id, path, rival in removed: print( - f"Another addon already embeds {path.rsplit('/', 1)[-1]}; " - f"using {rival} instead of our copy." + f"Runtime embed check: Another addon already embeds " + f"{path.rsplit('/', 1)[-1]}; using {rival} instead of our copy." ) return text diff --git a/apps/game/addons/godot-iap/types.gd b/apps/game/addons/godot-iap/types.gd index b2eb5f6..7701426 100644 --- a/apps/game/addons/godot-iap/types.gd +++ b/apps/game/addons/godot-iap/types.gd @@ -2248,7 +2248,7 @@ class PurchaseAndroid: var transaction_date: float = 0.0 var purchase_token: Variant = null ## Store where purchase was made - var store := IapStore.UNKNOWN + var store: IapStore = IapStore.UNKNOWN var quantity: int = 0 var purchase_state: PurchaseState = PurchaseState.UNKNOWN var is_auto_renewing: bool = false @@ -2473,7 +2473,7 @@ class PurchaseIOS: var transaction_date: float = 0.0 var purchase_token: Variant = null ## Store where purchase was made - var store := IapStore.UNKNOWN + var store: IapStore = IapStore.UNKNOWN var quantity: int = 0 var purchase_state: PurchaseState = PurchaseState.UNKNOWN var is_auto_renewing: bool = false @@ -2967,7 +2967,7 @@ class RentalDetailsAndroid: return dict class RequestVerifyPurchaseWithIapkitResult: - var store := IapStore.UNKNOWN + var store: IapStore = IapStore.UNKNOWN ## Available in OpenIAP 3.2.0 / openiap-apple 3.2.0 / openiap-google 3.3.0. Amazon RVS environment selected by IAPKit. Present as `Sandbox` or `Production` on handled Amazon verification results. Deliberately String, not an enum: the value space belongs to IAPKit and the stores behind it, and Apple's App Store Server alone also names `Xcode` and `LocalTesting`. SDKs must forward this value opaquely. Never reject a verification because the environment is unrecognised — that fails a purchase the store already confirmed. var environment: Variant = null ## True when the purchase is valid and actionable. Only entitled, pending-acknowledgment, or ready-to-consume return true. Callers must still match productId and use the platform plus app-owned product type to choose the fulfillment path. diff --git a/apps/game/export_presets.cfg b/apps/game/export_presets.cfg index 47948e7..9bb3a08 100644 --- a/apps/game/export_presets.cfg +++ b/apps/game/export_presets.cfg @@ -69,7 +69,7 @@ advanced_options=false dedicated_server=false custom_features="iap_store" export_filter="all_resources" -include_filter="iapkit.cfg,firebase.cfg" +include_filter="iapkit_publishable.cfg,firebase.cfg" exclude_filter="tests/*,tools/*" export_path="../../builds/ios/MoonlitBeacon.ipa" patches=PackedStringArray() @@ -110,7 +110,7 @@ advanced_options=false dedicated_server=false custom_features="iap_store" export_filter="all_resources" -include_filter="iapkit.cfg,firebase.cfg" +include_filter="iapkit_publishable.cfg,firebase.cfg" exclude_filter="tests/*,tools/*" export_path="../../builds/android/MoonlitBeacon.aab" patches=PackedStringArray() @@ -126,6 +126,7 @@ script_export_mode=2 custom_template/debug="" custom_template/release="" plugins/GodotIap=true +openiap/android_store="play" gradle_build/use_gradle_build=true gradle_build/export_format=1 gradle_build/min_sdk="24" diff --git a/apps/game/project.godot b/apps/game/project.godot index 6baee73..52e9efc 100644 --- a/apps/game/project.godot +++ b/apps/game/project.godot @@ -40,7 +40,7 @@ Analytics="*res://scripts/analytics/analytics.gd" Vault="*res://scripts/gameplay/vault.gd" Ladder="*res://scripts/gameplay/ladder.gd" GlobalLadder="*res://scripts/gameplay/global_ladder.gd" -IapStore="*res://scripts/iap/iap_store.gd" +Shop="*res://scripts/iap/iap_store.gd" [display] diff --git a/apps/game/scripts/dev/store_capture_probe.gd b/apps/game/scripts/dev/store_capture_probe.gd index c25a973..c5ba377 100644 --- a/apps/game/scripts/dev/store_capture_probe.gd +++ b/apps/game/scripts/dev/store_capture_probe.gd @@ -104,7 +104,7 @@ static func poll(scene: Node) -> void: static func _direct_distribution_state(scene: Node) -> Dictionary: - var store: Node = scene.get_node_or_null("/root/IapStore") + var store: Node = scene.get_node_or_null("/root/Shop") var screen: CanvasItem = scene.get_node_or_null("Ui/Screen") as CanvasItem var store_button: Button = scene.get_node_or_null( "Ui/Screen/StoreButton") as Button diff --git a/apps/game/scripts/gameplay/vault.gd b/apps/game/scripts/gameplay/vault.gd index fb182fd..fb7238a 100644 --- a/apps/game/scripts/gameplay/vault.gd +++ b/apps/game/scripts/gameplay/vault.gd @@ -554,7 +554,7 @@ var continue_coins: int = STARTING_COINS var continue_coin_grants: Dictionary = {} -## Grant coins. Only `IapStore` calls this after a real payment is verified. +## Grant coins. Only `Shop` calls this after a real payment is verified. ## ## Balance and transaction key land in one atomic save. Whether the app dies ## before or after that write, both are absent or both are present, so the same diff --git a/apps/game/scripts/iap/godot_iap_3_compat.gd b/apps/game/scripts/iap/godot_iap_3_compat.gd deleted file mode 100644 index 85a889c..0000000 --- a/apps/game/scripts/iap/godot_iap_3_compat.gd +++ /dev/null @@ -1,148 +0,0 @@ -extends RefCounted - -## Preserve native query failures that the public Array API would collapse. -## -## Upstream `get_available_purchases()` still maps a store/bridge failure to an -## empty list. Entitlement sync cannot treat those the same, so this narrow -## adapter reads the Android result envelope and the Apple async envelope. -## Every other call goes through the official 3.x wrapper. - -const PLATFORM_ANDROID: String = "Android" -const PLATFORM_IOS: String = "iOS" - -var _plugin: Node - - -func _init(plugin: Node) -> void: - _plugin = plugin - - -func available_purchases(options: Variant = null) -> Dictionary: - if _plugin == null: - return _failure() - var platform: String = str(_plugin.get_platform()) - if platform == PLATFORM_ANDROID: - return _android_available_purchases(options) - if platform == PLATFORM_IOS: - return await _ios_available_purchases(options) - return _failure() - - -func restore_purchases() -> Dictionary: - if _plugin == null: - return _failure() - var platform: String = str(_plugin.get_platform()) - if platform == PLATFORM_ANDROID: - return _android_void_result("restorePurchases") - if platform == PLATFORM_IOS: - var result: Variant = await _plugin.restore_purchases() - return { - "success": _result_success(result), - } - return _failure() - - -func _android_available_purchases(options: Variant) -> Dictionary: - var native: Variant = _native_plugin() - if native == null: - return _failure() - var response_json: Variant - if options == null: - response_json = native.call("getAvailablePurchasesResult") - else: - response_json = native.call( - "getAvailablePurchasesResultWithOptions", - JSON.stringify(_as_dictionary(options))) - return _parse_available_result(response_json) - - -func _ios_available_purchases(options: Variant) -> Dictionary: - var payload: Variant - if _plugin.has_method("_call_apple_async"): - payload = await _plugin._call_apple_async( - "getAvailablePurchases", - [JSON.stringify(_as_dictionary(options))]) - elif _plugin.has_method("_call_ios_async"): - payload = await _plugin._call_ios_async( - "getAvailablePurchases", - [JSON.stringify(_as_dictionary(options))]) - else: - return _failure() - if payload is not Dictionary or not bool(payload.get("success", false)): - return _failure() - var purchases: Variant = _parse_json( - str(payload.get("purchasesJson", ""))) - if purchases is not Array: - return _failure() - return { - "success": true, - "purchases": purchases, - } - - -func _android_void_result(method: String) -> Dictionary: - var native: Variant = _native_plugin() - if native == null: - return _failure() - var parsed: Variant = _parse_json(native.call(method)) - if parsed is not Dictionary or typeof(parsed.get("success", null)) != TYPE_BOOL: - return _failure() - return { - "success": bool(parsed.get("success", false)), - } - - -func _parse_available_result(response_json: Variant) -> Dictionary: - var parsed: Variant = _parse_json(response_json) - if parsed is not Dictionary \ - or typeof(parsed.get("success", null)) != TYPE_BOOL \ - or parsed.get("purchases", null) is not Array: - return _failure() - if not bool(parsed.get("success", false)): - return _failure() - return { - "success": true, - "purchases": parsed.get("purchases", []), - } - - -func _native_plugin() -> Variant: - return _plugin.get("_native_plugin") - - -func _parse_json(value: Variant) -> Variant: - var parser: JSON = JSON.new() - if parser.parse(str(value)) != OK: - return null - return parser.data - - -func _result_success(result: Variant) -> bool: - if result is Dictionary: - return typeof(result.get("success", null)) == TYPE_BOOL \ - and bool(result.get("success", false)) - if typeof(result) == TYPE_OBJECT and is_instance_valid(result): - var value: Variant = result.get("success") - return typeof(value) == TYPE_BOOL and bool(value) - return false - - -func _as_dictionary(value: Variant) -> Dictionary: - if value == null: - return {} - if value is Dictionary: - return value - if typeof(value) == TYPE_OBJECT \ - and is_instance_valid(value) \ - and value.has_method("to_dict"): - var converted: Variant = value.to_dict() - if converted is Dictionary: - return converted - return {} - - -func _failure() -> Dictionary: - return { - "success": false, - "purchases": [], - } diff --git a/apps/game/scripts/iap/godot_iap_3_compat.gd.uid b/apps/game/scripts/iap/godot_iap_3_compat.gd.uid deleted file mode 100644 index b454c92..0000000 --- a/apps/game/scripts/iap/godot_iap_3_compat.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cgmxbchhn8oqs diff --git a/apps/game/scripts/iap/godot_iap_backend.gd b/apps/game/scripts/iap/godot_iap_backend.gd index aa51051..c95d98c 100644 --- a/apps/game/scripts/iap/godot_iap_backend.gd +++ b/apps/game/scripts/iap/godot_iap_backend.gd @@ -1,17 +1,16 @@ extends "res://scripts/iap/iap_backend.gd" -## Adapt godot-iap 3.5.1 to the game's small backend contract. +## Adapt godot-iap to the game's small backend contract. ## ## `request_purchase()` is a command that opens the store sheet, not a function ## that returns the result. The terminal outcome always arrives on ## `purchase_updated` / `purchase_failed`. const Types = preload("res://addons/godot-iap/types.gd") -const COMPAT_SCRIPT: Script = preload( - "res://scripts/iap/godot_iap_3_compat.gd") const IAPKIT_TRANSPORT_SCRIPT: Script = preload( "res://scripts/iap/iapkit_http_transport.gd") -const IAPKIT_CONFIG_PATH: String = "res://iapkit.cfg" +## Not res://iapkit.cfg: godot-iap 3.6+ leaves that file out of release exports. +const IAPKIT_CONFIG_PATH: String = "res://iapkit_publishable.cfg" const IAPKIT_SECTION: String = "iapkit" const IAPKIT_KEY_PREFIX: String = "openiap-kit_pk_" ## How long to wait when a product query never answers. @@ -31,7 +30,6 @@ var _signals_connected: bool = false var _iapkit_api_key: String = "" var _iapkit_config_loaded: bool = false var _iapkit_transport: Node -var _compat: RefCounted var _purchase_dispatch_in_progress: bool = false var _purchase_dispatch_failed: bool = false var _purchase_request_generation: int = 0 @@ -48,8 +46,6 @@ func _init( _iapkit_api_key = iapkit_api_key.strip_edges() _iapkit_config_loaded = not _iapkit_api_key.is_empty() _iapkit_transport = iapkit_transport - if _plugin != null: - _compat = COMPAT_SCRIPT.new(_plugin) func initialize() -> bool: @@ -60,7 +56,6 @@ func initialize() -> bool: _plugin = get_node_or_null("/root/GodotIapPlugin") if _plugin == null: return false - _compat = COMPAT_SCRIPT.new(_plugin) _connect_plugin_signals() return bool(await _plugin.init_connection()) @@ -154,14 +149,14 @@ func request_purchase(product_id: String) -> bool: func restore() -> bool: - if _plugin == null or _compat == null: + if _plugin == null: return false - var result: Dictionary = await _compat.restore_purchases() - return bool(result.get("success", false)) + var result: Variant = await _plugin.restore_purchases() + return result != null and bool(result.success) func available_purchases() -> Dictionary: - if _plugin == null or _compat == null: + if _plugin == null: return {"success": false, "purchases": []} var options: RefCounted = Types.PurchaseOptions.new() # StoreKit's default Transaction.all also returns finished refund/revocation @@ -169,7 +164,8 @@ func available_purchases() -> Dictionary: # finished ledger rows are re-verified separately through IAPKit with the # stored JWS. options.only_include_active_items_ios = true - var query: Variant = await _compat.available_purchases(options) + # The result form keeps a failed query apart from an empty purchase list. + var query: Variant = await _plugin.get_available_purchases_result(options) if query is not Dictionary or not bool(query.get("success", false)): return {"success": false, "purchases": []} var result: Array[Dictionary] = [] @@ -177,16 +173,8 @@ func available_purchases() -> Dictionary: if available is not Array: return {"success": false, "purchases": []} for purchase in available: - var value: Variant - if purchase is Dictionary: - value = purchase.duplicate(true) - elif typeof(purchase) == TYPE_OBJECT \ - and is_instance_valid(purchase) \ - and purchase.has_method("to_dict"): - value = purchase.to_dict() - else: - return {"success": false, "purchases": []} - if value is not Dictionary or not _available_purchase_is_valid(value): + var value: Dictionary = _as_dictionary(purchase) + if not _available_purchase_is_valid(value): return {"success": false, "purchases": []} result.append(value) return { diff --git a/apps/game/scripts/objectives/beacon.gd b/apps/game/scripts/objectives/beacon.gd index 810632a..cf17b2d 100644 --- a/apps/game/scripts/objectives/beacon.gd +++ b/apps/game/scripts/objectives/beacon.gd @@ -94,7 +94,7 @@ func _ready() -> void: if Engine.is_editor_hint(): return - _iap_store = get_node_or_null("/root/IapStore") + _iap_store = get_node_or_null("/root/Shop") if _iap_store != null: _iap_store.lantern_changed.connect(_on_lantern_changed) _apply_lantern_palette() diff --git a/apps/game/scripts/ui/credits_panel.gd b/apps/game/scripts/ui/credits_panel.gd index 56d4ac7..79a956c 100644 --- a/apps/game/scripts/ui/credits_panel.gd +++ b/apps/game/scripts/ui/credits_panel.gd @@ -30,7 +30,7 @@ const ROWS: Array = [ "Maplestory · ⓒ NEXON Korea\nNoto Sans CJK SC · Google\n" + "SIL Open Font License 1.1", ], - ["CREDITS_IAP", "godot-iap 3.5.1\nOpenIAP contributors · MIT License"], + ["CREDITS_IAP", "godot-iap 3.6.1\nOpenIAP contributors · MIT License"], ["CREDITS_MADE_BY", "Hyo Dev"], ] @@ -65,7 +65,7 @@ func _redraw() -> void: # If the name is two lines, pad the left column to keep the rows aligned. for i in value.count("\n"): roles.append("") - var store: Node = get_node_or_null("/root/IapStore") + var store: Node = get_node_or_null("/root/Shop") if store != null and store.owns(store.SUPPORTER): var supporter_name: String = str(Ladder.last_name).strip_edges() if supporter_name.is_empty(): diff --git a/apps/game/scripts/ui/hero_preview_panel.gd b/apps/game/scripts/ui/hero_preview_panel.gd index 04c1cb6..94f0156 100644 --- a/apps/game/scripts/ui/hero_preview_panel.gd +++ b/apps/game/scripts/ui/hero_preview_panel.gd @@ -4,7 +4,7 @@ extends Control ## Read-only full-body preview, separate from hero buy/select. ## ## Reads only the current Hero resource and writes nothing to Vault or -## IapStore. Shrine shard unlocks and shop device purchases happen only on +## Shop. Shrine shard unlocks and shop device purchases happen only on ## each parent panel's existing buttons. signal closed diff --git a/apps/game/scripts/ui/iap_shop_panel.gd b/apps/game/scripts/ui/iap_shop_panel.gd index 8510a22..9d0ecb8 100644 --- a/apps/game/scripts/ui/iap_shop_panel.gd +++ b/apps/game/scripts/ui/iap_shop_panel.gd @@ -33,7 +33,7 @@ const LANTERN_ARTWORK_PALETTES: Array[String] = [ "violet", "jade", ] -## If a live sell card reuses IapStore's own returned value as expected, a +## If a live sell card reuses Shop's own returned value as expected, a ## miswired SKU still passes. Capture proof compares five hero resources and ## portraits against a literal contract independent of the mapping that ## builds sell cards. @@ -103,20 +103,20 @@ var _capture_selected_product_id: String = "" func _ready() -> void: _close.pressed.connect(close) _restore.pressed.connect(_restore_or_retry) - IapStore.state_changed.connect(_on_store_state_changed) - IapStore.interaction_changed.connect(_rebuild) - IapStore.products_changed.connect(_rebuild) - IapStore.entitlement_changed.connect(_on_entitlement_changed) + Shop.state_changed.connect(_on_store_state_changed) + Shop.interaction_changed.connect(_rebuild) + Shop.products_changed.connect(_rebuild) + Shop.entitlement_changed.connect(_on_entitlement_changed) # Coins are consumable so they do not ride the entitlement signal. Balance # only changes in Vault, so listen there directly — the number rises # right after a buy. Vault.changed.connect(_refresh_coin_balance) - IapStore.purchase_succeeded.connect(_on_purchase_succeeded) - IapStore.purchase_pending.connect(_on_purchase_pending) - IapStore.purchase_failed.connect(_on_purchase_failed) - IapStore.purchase_revoked.connect(_on_purchase_revoked) - IapStore.restore_finished.connect(_on_restore_finished) - IapStore.lantern_changed.connect(_on_lantern_changed) + Shop.purchase_succeeded.connect(_on_purchase_succeeded) + Shop.purchase_pending.connect(_on_purchase_pending) + Shop.purchase_failed.connect(_on_purchase_failed) + Shop.purchase_revoked.connect(_on_purchase_revoked) + Shop.restore_finished.connect(_on_restore_finished) + Shop.lantern_changed.connect(_on_lantern_changed) _rebuild() @@ -147,7 +147,7 @@ func open() -> void: func _refresh_coin_balance() -> void: var coins: int = Vault.continue_coins _coin_balance.text = tr("IAP_COIN_BALANCE") % coins - _coin_balance.visible = IapStore.storefront_enabled() + _coin_balance.visible = Shop.storefront_enabled() func close() -> void: @@ -169,36 +169,36 @@ func _rebuild() -> void: # Coins on top, characters below. Someone who died into the shop is # looking for continue coins first, not a new hero. _clear(_coins) - for product_id in IapStore.COIN_SALE_PRODUCT_IDS: + for product_id in Shop.COIN_SALE_PRODUCT_IDS: _coins.add_child(_make_product_card(product_id)) _clear(_cards) - for product_id in IapStore.PERMANENT_SALE_PRODUCT_IDS: + for product_id in Shop.PERMANENT_SALE_PRODUCT_IDS: _cards.add_child(_make_product_card(product_id)) _rebuild_palettes() - var can_retry: bool = IapStore.can_retry_connection() + var can_retry: bool = Shop.can_retry_connection() _restore.text = tr("IAP_RETRY") if can_retry else tr("IAP_RESTORE") - _restore.disabled = not can_retry and IapStore.state != IapStore.StoreState.READY + _restore.disabled = not can_retry and Shop.state != Shop.StoreState.READY if _status.text.is_empty(): _set_store_status() func _on_store_state_changed() -> void: - if IapStore.state in [ - IapStore.StoreState.LOADING, - IapStore.StoreState.UNAVAILABLE, - IapStore.StoreState.READY, - IapStore.StoreState.ERROR, + if Shop.state in [ + Shop.StoreState.LOADING, + Shop.StoreState.UNAVAILABLE, + Shop.StoreState.READY, + Shop.StoreState.ERROR, ]: _set_store_status() _rebuild() func _make_product_card(product_id: String) -> PanelContainer: - var catalog: Dictionary = IapStore.catalog_entry(product_id) - var details: Dictionary = IapStore.product_details(product_id) + var catalog: Dictionary = Shop.catalog_entry(product_id) + var details: Dictionary = Shop.product_details(product_id) var accent: Color = catalog.get("accent", Color.WHITE) - var owned: bool = IapStore.owns(product_id) - var earned: bool = IapStore.benefit_already_earned(product_id) + var owned: bool = Shop.owns(product_id) + var earned: bool = Shop.benefit_already_earned(product_id) var card: PanelContainer = PanelContainer.new() var product_key: String = product_id.get_slice( ".", product_id.get_slice_count(".") - 1) @@ -206,7 +206,7 @@ func _make_product_card(product_id: String) -> PanelContainer: card.set_meta(&"product_id", product_id) # Coin cards have no art, so they are that much shorter. The two-row shop # still fitting Pixel 10's 337px safe area is because of that gap. - var coin: bool = IapStore.is_consumable(product_id) + var coin: bool = Shop.is_consumable(product_id) card.custom_minimum_size = Vector2(200, 68) if coin else Vector2(246, 98) if coin: card.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -244,20 +244,20 @@ func _make_product_card(product_id: String) -> PanelContainer: content.add_theme_constant_override("separation", 6) rows.add_child(content) - if product_id == IapStore.HERO_BUNDLE or IapStore.is_hero_product(product_id): + if product_id == Shop.HERO_BUNDLE or Shop.is_hero_product(product_id): var hero_previews: HBoxContainer = HBoxContainer.new() hero_previews.name = &"HeroPreviews" hero_previews.add_theme_constant_override("separation", 3) var hero_paths: Array[String] = [] - if product_id == IapStore.HERO_BUNDLE: - hero_paths.assign(IapStore.HERO_BUNDLE_PATHS) + if product_id == Shop.HERO_BUNDLE: + hero_paths.assign(Shop.HERO_BUNDLE_PATHS) else: - hero_paths.append(IapStore.hero_path_for_product(product_id)) + hero_paths.append(Shop.hero_path_for_product(product_id)) for path in hero_paths: var hero: Hero = load(path) as Hero if hero != null: hero_previews.add_child(_make_hero_preview_button(hero, path)) - if product_id != IapStore.HERO_BUNDLE: + if product_id != Shop.HERO_BUNDLE: content.mouse_filter = Control.MOUSE_FILTER_PASS content.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND content.tooltip_text = tr("HERO_PREVIEW_VIEW") % tr(hero.display_name) @@ -292,7 +292,7 @@ func _make_product_card(product_id: String) -> PanelContainer: elif earned: price = _label("✓ " + tr("IAP_ALREADY_EARNED"), price_size, SUCCESS, true) else: - var localized_price: String = IapStore.display_price(product_id) + var localized_price: String = Shop.display_price(product_id) price = _label( localized_price if not localized_price.is_empty() else _unavailable_price_text(), price_size, accent if not localized_price.is_empty() else MUTED, true) @@ -302,7 +302,7 @@ func _make_product_card(product_id: String) -> PanelContainer: var action: Button = _button( tr("IAP_OWNED") if owned else tr("IAP_ALREADY_EARNED") if earned \ else tr("IAP_BUY"), accent, coin) - action.disabled = owned or earned or not IapStore.can_purchase(product_id) + action.disabled = owned or earned or not Shop.can_purchase(product_id) if not owned and not earned: action.pressed.connect(_buy.bind(product_id)) action.name = &"Action" @@ -347,9 +347,9 @@ func _open_hero_preview(hero: Hero, path: String) -> void: func _make_product_artwork( product_id: String, accent: Color) -> PanelContainer: - if product_id == IapStore.SUPPORTER: + if product_id == Shop.SUPPORTER: return _make_supporter_artwork(accent) - if product_id == IapStore.LANTERN_COLORS: + if product_id == Shop.LANTERN_COLORS: return _make_lantern_artwork(accent) return null @@ -384,7 +384,7 @@ func _make_lantern_artwork(accent: Color) -> PanelContainer: StringName("Flame" + palette_id.to_pascal_case()), frame, Vector2(20, 38)) - var palette: Dictionary = IapStore.PALETTES[palette_id] + var palette: Dictionary = Shop.PALETTES[palette_id] flame.self_modulate = palette.get("light", Color.WHITE) flame.set_meta(&"artwork_item_kind", LANTERN_ARTWORK_KIND) flame.set_meta(&"artwork_palette_id", palette_id) @@ -441,7 +441,7 @@ func debug_prepare_store_capture(request: Dictionary) -> void: var product_id: String = str(request.get("product_id", "")) var card: Control = _debug_product_card(product_id) var scroll: ScrollContainer = _coins_scroll \ - if IapStore.is_consumable(product_id) else _cards_scroll + if Shop.is_consumable(product_id) else _cards_scroll if card == null or card.size.x <= 0.0 or scroll.size.x <= 0.0: return # Capture prep only selects a product card and moves it to screen center. @@ -464,7 +464,7 @@ func debug_store_capture_state(request: Dictionary) -> Dictionary: if card == null: return {} var scroll: ScrollContainer = _coins_scroll \ - if IapStore.is_consumable(expected_product_id) else _cards_scroll + if Shop.is_consumable(expected_product_id) else _cards_scroll var viewport_rect: Rect2 = scroll.get_global_rect() var card_rect: Rect2 = card.get_global_rect() var screen_rect: Rect2 = get_viewport().get_visible_rect() @@ -633,7 +633,7 @@ func debug_store_capture_state(request: Dictionary) -> Dictionary: and restore_font_size_positive and restore_font_alpha_readable var review_fallback_verified: bool = TranslationServer.get_locale() == "ko" \ and _capture_selected_product_id == expected_product_id \ - and IapStore.state == IapStore.StoreState.UNAVAILABLE \ + and Shop.state == Shop.StoreState.UNAVAILABLE \ and price_copy_valid and review_status_copy_valid \ and action != null and action.disabled and action_copy_valid \ and _restore.disabled and restore_copy_valid @@ -736,9 +736,9 @@ func debug_store_capture_state(request: Dictionary) -> Dictionary: "viewport_rect": [viewport_rect.position.x, viewport_rect.position.y, viewport_rect.size.x, viewport_rect.size.y], } - var expects_portrait: bool = expected_product_id == IapStore.HERO_BUNDLE \ - or IapStore.is_hero_product(expected_product_id) - var expects_text_only_card: bool = IapStore.is_consumable(expected_product_id) + var expects_portrait: bool = expected_product_id == Shop.HERO_BUNDLE \ + or Shop.is_hero_product(expected_product_id) + var expects_text_only_card: bool = Shop.is_consumable(expected_product_id) var product_visual_ready: bool = true if expects_text_only_card \ else portrait_visible if expects_portrait else artwork_visible state["ready"] = bool(state["shop_visible"]) \ @@ -847,17 +847,17 @@ func _debug_expected_action_text(product_id: String) -> String: func _debug_expected_artwork_kind(product_id: String) -> String: - if product_id == IapStore.SUPPORTER: + if product_id == Shop.SUPPORTER: return SUPPORTER_ARTWORK_KIND - if product_id == IapStore.LANTERN_COLORS: + if product_id == Shop.LANTERN_COLORS: return LANTERN_ARTWORK_KIND return "" func _debug_expected_artwork_count(product_id: String) -> int: - if product_id == IapStore.SUPPORTER: + if product_id == Shop.SUPPORTER: return 1 - if product_id == IapStore.LANTERN_COLORS: + if product_id == Shop.LANTERN_COLORS: return LANTERN_ARTWORK_PALETTES.size() return 0 @@ -879,19 +879,19 @@ func _debug_collect_artwork_textures( func _debug_artwork_item_matches( item: TextureRect, product_id: String, index: int) -> bool: - if product_id == IapStore.SUPPORTER: + if product_id == Shop.SUPPORTER: return index == 0 \ and str(item.get_meta(&"artwork_item_kind", "")) \ == SUPPORTER_ARTWORK_KIND \ and item.texture == SUPPORTER_ICON - if product_id != IapStore.LANTERN_COLORS \ + if product_id != Shop.LANTERN_COLORS \ or index < 0 or index >= LANTERN_ARTWORK_PALETTES.size(): return false var palette_id: String = LANTERN_ARTWORK_PALETTES[index] var atlas: AtlasTexture = item.texture as AtlasTexture if atlas == null: return false - var palette: Dictionary = IapStore.PALETTES[palette_id] + var palette: Dictionary = Shop.PALETTES[palette_id] return str(item.get_meta(&"artwork_item_kind", "")) \ == LANTERN_ARTWORK_KIND \ and str(item.get_meta(&"artwork_palette_id", "")) == palette_id \ @@ -941,10 +941,10 @@ func _debug_product_card(product_id: String) -> Control: func _unavailable_price_text() -> String: - match IapStore.state: - IapStore.StoreState.LOADING: + match Shop.state: + Shop.StoreState.LOADING: return tr("IAP_CONNECTING") - IapStore.StoreState.UNAVAILABLE: + Shop.StoreState.UNAVAILABLE: return tr("IAP_DEVICE_ONLY") _: return tr("IAP_PRODUCT_UNAVAILABLE") @@ -952,15 +952,15 @@ func _unavailable_price_text() -> String: func _rebuild_palettes() -> void: _clear(_palette_buttons) - var unlocked: bool = IapStore.owns(IapStore.LANTERN_COLORS) + var unlocked: bool = Shop.owns(Shop.LANTERN_COLORS) _palette_title.text = tr( "IAP_PALETTE_READY" if unlocked else "IAP_PALETTE_LOCKED") _palette_title.add_theme_color_override( "font_color", SUCCESS if unlocked else MUTED) - for palette_id in IapStore.PALETTES: - var data: Dictionary = IapStore.PALETTES[palette_id] - var selected: bool = palette_id == IapStore.selected_palette - var available: bool = palette_id in IapStore.available_palettes() + for palette_id in Shop.PALETTES: + var data: Dictionary = Shop.PALETTES[palette_id] + var selected: bool = palette_id == Shop.selected_palette + var available: bool = palette_id in Shop.available_palettes() var button: Button = _button( ("◆ " if selected else "") + tr(str(data.get("name", ""))), data.get("light", Color.WHITE), true) @@ -974,26 +974,26 @@ func _rebuild_palettes() -> void: func _buy(product_id: String) -> void: _status.text = tr("IAP_OPENING_STORE") _status.add_theme_color_override("font_color", TEXT) - IapStore.purchase(product_id) + Shop.purchase(product_id) _rebuild() func _restore_or_retry() -> void: - if IapStore.can_retry_connection(): + if Shop.can_retry_connection(): _status.text = tr("IAP_RETRYING") _status.add_theme_color_override("font_color", TEXT) _rebuild() - await IapStore.retry_connection() + await Shop.retry_connection() return _status.text = tr("IAP_RESTORING") _status.add_theme_color_override("font_color", TEXT) _rebuild() - await IapStore.restore_purchases() + await Shop.restore_purchases() func _select_palette(palette_id: String) -> void: - if IapStore.select_palette(palette_id): - _status.text = tr("IAP_PALETTE_SELECTED") % IapStore.palette_name() + if Shop.select_palette(palette_id): + _status.text = tr("IAP_PALETTE_SELECTED") % Shop.palette_name() _status.add_theme_color_override("font_color", SUCCESS) $Sfx.play() _rebuild() @@ -1004,7 +1004,7 @@ func _on_entitlement_changed(_product_id: String) -> void: func _on_purchase_succeeded(product_id: String) -> void: - var catalog: Dictionary = IapStore.catalog_entry(product_id) + var catalog: Dictionary = Shop.catalog_entry(product_id) _status.text = tr("IAP_PURCHASED") % tr(str(catalog.get("title", ""))) _status.add_theme_color_override("font_color", SUCCESS) $Sfx.play() @@ -1024,8 +1024,8 @@ func _on_purchase_failed(product_id: String, code: String) -> void: # arrives. First buy on a physical Pixel 10 did exactly that — product # "owned" and "payment could not complete" together. Ownership is the # truth, so follow that. - if not product_id.is_empty() and IapStore.owns(product_id): - var owned_catalog: Dictionary = IapStore.catalog_entry(product_id) + if not product_id.is_empty() and Shop.owns(product_id): + var owned_catalog: Dictionary = Shop.catalog_entry(product_id) _status.text = tr("IAP_PURCHASED") % tr(str(owned_catalog.get("title", ""))) _status.add_theme_color_override("font_color", SUCCESS) _rebuild() @@ -1064,24 +1064,24 @@ func _on_lantern_changed(_palette_id: String) -> void: func _set_store_status() -> void: - match IapStore.state: - IapStore.StoreState.LOADING: + match Shop.state: + Shop.StoreState.LOADING: _status.text = tr("IAP_CONNECTING_LONG") _status.add_theme_color_override("font_color", TEXT) - IapStore.StoreState.UNAVAILABLE: + Shop.StoreState.UNAVAILABLE: _status.text = tr("IAP_DEVICE_STORE_NOTE") _status.add_theme_color_override("font_color", MUTED) - IapStore.StoreState.ERROR: + Shop.StoreState.ERROR: _status.text = tr("IAP_PRODUCT_LOAD_FAILED") _status.add_theme_color_override("font_color", ERROR) - IapStore.StoreState.PURCHASING: + Shop.StoreState.PURCHASING: _status.text = tr("IAP_OPENING_STORE") _status.add_theme_color_override("font_color", TEXT) - IapStore.StoreState.PENDING: + Shop.StoreState.PENDING: _status.text = tr("IAP_PENDING") _status.add_theme_color_override( "font_color", Color(1.0, 0.82, 0.46, 1.0)) - IapStore.StoreState.RESTORING: + Shop.StoreState.RESTORING: _status.text = tr("IAP_RESTORING") _status.add_theme_color_override("font_color", TEXT) _: diff --git a/apps/game/scripts/ui/shrine_panel.gd b/apps/game/scripts/ui/shrine_panel.gd index 203df2a..9628ed2 100644 --- a/apps/game/scripts/ui/shrine_panel.gd +++ b/apps/game/scripts/ui/shrine_panel.gd @@ -55,14 +55,14 @@ func _ready() -> void: _apply_responsive_layout() _close.pressed.connect(close) Vault.changed.connect(_on_vault_changed) - IapStore.state_changed.connect(_on_iap_state_changed) - IapStore.interaction_changed.connect(_on_iap_state_changed) - IapStore.products_changed.connect(_on_iap_state_changed) - IapStore.entitlement_changed.connect(_on_iap_entitlement_changed) - IapStore.purchase_succeeded.connect(_on_iap_purchase_succeeded) - IapStore.purchase_pending.connect(_on_iap_purchase_pending) - IapStore.purchase_failed.connect(_on_iap_purchase_failed) - IapStore.purchase_revoked.connect(_on_iap_purchase_revoked) + Shop.state_changed.connect(_on_iap_state_changed) + Shop.interaction_changed.connect(_on_iap_state_changed) + Shop.products_changed.connect(_on_iap_state_changed) + Shop.entitlement_changed.connect(_on_iap_entitlement_changed) + Shop.purchase_succeeded.connect(_on_iap_purchase_succeeded) + Shop.purchase_pending.connect(_on_iap_purchase_pending) + Shop.purchase_failed.connect(_on_iap_purchase_failed) + Shop.purchase_revoked.connect(_on_iap_purchase_revoked) func open() -> void: @@ -231,14 +231,14 @@ func _make_hero_card(hero: Hero, path: String) -> PanelContainer: if not selected: action.pressed.connect(_choose_hero.bind(hero, path)) elif path in Vault.PAID_HEROES: - var product_id: String = IapStore.hero_product_for_path(path) - var localized_price: String = IapStore.display_price(product_id) + var product_id: String = Shop.hero_product_for_path(path) + var localized_price: String = Shop.display_price(product_id) state = _label( localized_price if not localized_price.is_empty() \ else _hero_iap_price_text(), 11, hero.accent if not localized_price.is_empty() else MUTED) action = _button(tr("IAP_BUY"), hero.accent) - action.disabled = _busy or not IapStore.can_purchase(product_id) + action.disabled = _busy or not Shop.can_purchase(product_id) action.pressed.connect(_purchase_hero_iap.bind(product_id)) else: state = _price_label(hero.unlock_cost) @@ -256,10 +256,10 @@ func _make_hero_card(hero: Hero, path: String) -> PanelContainer: func _hero_iap_price_text() -> String: - match IapStore.state: - IapStore.StoreState.LOADING: + match Shop.state: + Shop.StoreState.LOADING: return tr("IAP_CONNECTING") - IapStore.StoreState.UNAVAILABLE: + Shop.StoreState.UNAVAILABLE: return tr("IAP_DEVICE_ONLY") _: return tr("IAP_PRODUCT_UNAVAILABLE") @@ -610,14 +610,14 @@ func _purchase_hero(hero: Hero, path: String) -> void: func _purchase_hero_iap(product_id: String) -> void: - if _busy or not IapStore.is_hero_product(product_id): + if _busy or not Shop.is_hero_product(product_id): return _busy = true _disable_actions() _feedback_text = tr("IAP_OPENING_STORE") _feedback_error = false _rebuild() - if IapStore.purchase(product_id): + if Shop.purchase(product_id): return _busy = false _set_feedback(_hero_iap_price_text(), true) @@ -695,30 +695,30 @@ func _on_iap_entitlement_changed(_product_id: String) -> void: func _on_iap_purchase_succeeded(product_id: String) -> void: - if not visible or not IapStore.is_hero_product(product_id): + if not visible or not Shop.is_hero_product(product_id): return _busy = false - var hero: Hero = load(IapStore.hero_path_for_product(product_id)) as Hero + var hero: Hero = load(Shop.hero_path_for_product(product_id)) as Hero var display_name: String = tr(hero.display_name) if hero != null \ - else tr(str(IapStore.catalog_entry(product_id).get("title", ""))) + else tr(str(Shop.catalog_entry(product_id).get("title", ""))) _set_feedback(tr("IAP_PURCHASED") % display_name, false, true) func _on_iap_purchase_pending(product_id: String) -> void: - if not visible or not IapStore.is_hero_product(product_id): + if not visible or not Shop.is_hero_product(product_id): return _busy = false _set_feedback(tr("IAP_PENDING"), false) func _on_iap_purchase_failed(product_id: String, code: String) -> void: - if not visible or not IapStore.is_hero_product(product_id): + if not visible or not Shop.is_hero_product(product_id): return _busy = false # Do not show failure copy on a product already granted. Same reason as # the shop panel — Play's first purchase can deliver a late failure code # that disagrees with ownership. - if IapStore.owns(product_id): + if Shop.owns(product_id): _on_iap_purchase_succeeded(product_id) return if code in ["user-cancelled", "user-canceled", "cancelled", "canceled"]: @@ -732,7 +732,7 @@ func _on_iap_purchase_failed(product_id: String, code: String) -> void: func _on_iap_purchase_revoked(product_id: String) -> void: - if not visible or not IapStore.is_hero_product(product_id): + if not visible or not Shop.is_hero_product(product_id): return _busy = false _set_feedback(tr("IAP_REVOKED"), false) diff --git a/apps/game/scripts/ui/title_menu.gd b/apps/game/scripts/ui/title_menu.gd index d38d8c0..1420b51 100644 --- a/apps/game/scripts/ui/title_menu.gd +++ b/apps/game/scripts/ui/title_menu.gd @@ -159,7 +159,7 @@ func _ready() -> void: $Ui/Screen/LadderButton.pressed.connect(_open_ladder) _ladder.closed.connect(_on_panel_closed) var store_button: Button = $Ui/Screen/StoreButton - var storefront_enabled: bool = IapStore.storefront_enabled() + var storefront_enabled: bool = Shop.storefront_enabled() store_button.visible = storefront_enabled store_button.disabled = not storefront_enabled if store_button.visible: @@ -284,7 +284,7 @@ func debug_store_capture_state(request: Dictionary) -> Dictionary: var ladder_button: Button = $Ui/Screen/LadderButton var store_button: Button = $Ui/Screen/StoreButton var direct_distribution: bool = OS.has_feature("direct_distribution") - var storefront_enabled: bool = IapStore.storefront_enabled() + var storefront_enabled: bool = Shop.storefront_enabled() var store_button_visible: bool = store_button.is_visible_in_tree() var store_button_enabled: bool = not store_button.disabled var storefront_feature_matches: bool = storefront_enabled \ @@ -930,7 +930,7 @@ func _take_open_store_request() -> bool: return false var requested: bool = bool(root.get_meta(OPEN_STORE_META, false)) root.remove_meta(OPEN_STORE_META) - return requested and IapStore.storefront_enabled() + return requested and Shop.storefront_enabled() func _take_open_shrine_request() -> bool: diff --git a/apps/game/tests/support/fake_godot_iap_plugin.gd b/apps/game/tests/support/fake_godot_iap_plugin.gd index 3b67501..12dbcfd 100644 --- a/apps/game/tests/support/fake_godot_iap_plugin.gd +++ b/apps/game/tests/support/fake_godot_iap_plugin.gd @@ -1,6 +1,7 @@ extends Node -## Tiny plugin stand-in that only covers GodotIapBackend's command-dispatch boundary. +## Stand-in for the godot-iap wrapper at the backend's dispatch boundary, and +## for the native bridge when a test puts it behind the real wrapper. signal purchase_updated(purchase: Dictionary) signal purchase_error(error: Dictionary) @@ -20,16 +21,10 @@ var ios_available_payload: Dictionary = { "success": true, "purchasesJson": "[]", } -var ios_restore_success: bool = true -var _native_plugin: Node var _platform: String = "Android" var _store_connected: bool = true -func _init() -> void: - _native_plugin = self - - func get_platform() -> String: return _platform @@ -64,27 +59,16 @@ func getAvailablePurchasesResult() -> String: return native_available_result_json -func getAvailablePurchasesResultWithOptions(_options_json: String) -> String: - last_available_options = JSON.parse_string(_options_json) +func getAvailablePurchasesResultWithOptions(options_json: String) -> String: + last_available_options = JSON.parse_string(options_json) return native_available_result_json -func restorePurchases() -> String: - return native_restore_result_json - - -func _call_apple_async( - _method: String, - args: Array = [], - _timeout_seconds: float = -1.0) -> Dictionary: - return await _call_ios_async(_method, args) - - -func _call_ios_async(_method: String, args: Array = []) -> Dictionary: - if not args.is_empty(): - last_available_options = JSON.parse_string(str(args[0])) - return ios_available_payload.duplicate(true) +## Apple bridge: answer at once (no requestId) so the wrapper skips its async wait. +func getAvailablePurchases(options_json: String) -> String: + last_available_options = JSON.parse_string(options_json) + return JSON.stringify(ios_available_payload) -func restore_purchases() -> Dictionary: - return {"success": ios_restore_success} +func restorePurchases() -> String: + return native_restore_result_json diff --git a/apps/game/tests/test_iap_hero_previews.gd b/apps/game/tests/test_iap_hero_previews.gd index 75daad4..7427358 100644 --- a/apps/game/tests/test_iap_hero_previews.gd +++ b/apps/game/tests/test_iap_hero_previews.gd @@ -58,7 +58,7 @@ var _entitlements_before: Array[String] = [] func _ready() -> void: # The IAP review source and independent product-name contract is a Korean capture. TranslationServer.set_locale("ko") - # IapStore's desktop no-plugin init signal can redraw the card grid once more. + # Shop's desktop no-plugin init signal can redraw the card grid once more. await get_tree().process_frame await get_tree().process_frame _prepare_unavailable_store_fixture() @@ -67,24 +67,24 @@ func _ready() -> void: await get_tree().process_frame await get_tree().process_frame await _test_phone_safe_area_layout() - var bundle: Control = _product_card(IapStore.HERO_BUNDLE) + var bundle: Control = _product_card(Shop.HERO_BUNDLE) _expect_true(bundle == null, "hides the legacy hero_bundle from new sale cards") _test_coin_row_above_characters() var cards: HBoxContainer = _cards() _expect_equal( cards.get_child_count() + _coins().get_child_count(), - IapStore.SALE_PRODUCT_IDS.size(), + Shop.SALE_PRODUCT_IDS.size(), "new-sale product count") var first_preview: TextureButton = null - for index in IapStore.HERO_PRODUCT_IDS.size(): - var product_id: String = IapStore.HERO_PRODUCT_IDS[index] + for index in Shop.HERO_PRODUCT_IDS.size(): + var product_id: String = Shop.HERO_PRODUCT_IDS[index] var card: Control = _product_card(product_id) _expect_true(card != null, "%s individual hero card" % product_id) if card == null: continue _expect_equal( str(cards.get_child(index).get_meta(&"product_id", "")), - str(IapStore.PERMANENT_SALE_PRODUCT_IDS[index]), + str(Shop.PERMANENT_SALE_PRODUCT_IDS[index]), "the first five cards of the character row are heroes") var previews: HBoxContainer = card.get_node( "Rows/Content/HeroPreviews") as HBoxContainer @@ -94,7 +94,7 @@ func _ready() -> void: _expect_equal(previews.get_child_count(), 1, "%s one face" % product_id) if previews.get_child_count() == 1: var button: TextureButton = previews.get_child(0) as TextureButton - _test_preview_button(button, IapStore.hero_path_for_product(product_id)) + _test_preview_button(button, Shop.hero_path_for_product(product_id)) _test_preview_cta_separation(card, button) if first_preview == null: first_preview = button @@ -152,18 +152,18 @@ func _rect_fully_inside(inner: Rect2, outer: Rect2) -> bool: func _prepare_unavailable_store_fixture() -> void: _opened_before.assign(Vault.opened) _chosen_before = Vault.chosen - _entitlements_before.assign(IapStore.entitlements) + _entitlements_before.assign(Shop.entitlements) var paid_hero_paths: Array[String] = [] - for product_id in IapStore.HERO_PRODUCT_IDS: - paid_hero_paths.append(IapStore.hero_path_for_product(product_id)) + for product_id in Shop.HERO_PRODUCT_IDS: + paid_hero_paths.append(Shop.hero_path_for_product(product_id)) for hero_path in paid_hero_paths: Vault.opened.erase(hero_path) if Vault.chosen in paid_hero_paths: Vault.chosen = "" - IapStore.entitlements.clear() + Shop.entitlements.clear() _expect_equal( - IapStore.state, - IapStore.StoreState.UNAVAILABLE, + Shop.state, + Shop.StoreState.UNAVAILABLE, "actual unavailable state in debug with no native store") _shop.call("_rebuild") @@ -183,18 +183,18 @@ func _test_status_retranslated_on_open() -> void: func _expected_status() -> String: - match IapStore.state: - IapStore.StoreState.LOADING: + match Shop.state: + Shop.StoreState.LOADING: return tr("IAP_CONNECTING_LONG") - IapStore.StoreState.UNAVAILABLE: + Shop.StoreState.UNAVAILABLE: return tr("IAP_DEVICE_STORE_NOTE") - IapStore.StoreState.ERROR: + Shop.StoreState.ERROR: return tr("IAP_PRODUCT_LOAD_FAILED") - IapStore.StoreState.PURCHASING: + Shop.StoreState.PURCHASING: return tr("IAP_OPENING_STORE") - IapStore.StoreState.PENDING: + Shop.StoreState.PENDING: return tr("IAP_PENDING") - IapStore.StoreState.RESTORING: + Shop.StoreState.RESTORING: return tr("IAP_RESTORING") _: return tr("IAP_STORE_READY") @@ -231,21 +231,21 @@ func _test_coin_row_above_characters() -> void: var coins: HBoxContainer = _coins() _expect_equal( coins.get_child_count(), - IapStore.COIN_SALE_PRODUCT_IDS.size(), + Shop.COIN_SALE_PRODUCT_IDS.size(), "coin-bundle card count") # 1 → 5 → 12. Per-unit value must fall in screen order so the bundle # looks cheaper by eye. var last_grant: int = 0 - for index in mini(coins.get_child_count(), IapStore.COIN_SALE_PRODUCT_IDS.size()): - var product_id: String = str(IapStore.COIN_SALE_PRODUCT_IDS[index]) + for index in mini(coins.get_child_count(), Shop.COIN_SALE_PRODUCT_IDS.size()): + var product_id: String = str(Shop.COIN_SALE_PRODUCT_IDS[index]) _expect_equal( str(coins.get_child(index).get_meta(&"product_id", "")), product_id, "coin-row card %d" % index) - var grant: int = int(IapStore.CONSUMABLE_GRANTS.get(product_id, 0)) + var grant: int = int(Shop.CONSUMABLE_GRANTS.get(product_id, 0)) _expect_true(grant > last_grant, "%s grant larger than the previous bundle" % product_id) _expect_true( - IapStore.is_consumable(product_id), "%s consumable classification" % product_id) + Shop.is_consumable(product_id), "%s consumable classification" % product_id) last_grant = grant @@ -263,8 +263,8 @@ func _test_preview_button(button: TextureButton, path: String) -> void: hero.portrait.resource_path, "%s face-button portrait path" % path.get_file()) - var before_product: String = IapStore.current_product_id - var before_entitlements: Array[String] = IapStore.entitlements.duplicate() + var before_product: String = Shop.current_product_id + var before_entitlements: Array[String] = Shop.entitlements.duplicate() var before_shards: int = Vault.shards var before_opened: Array[String] = Vault.opened.duplicate() var before_chosen: String = Vault.chosen @@ -305,11 +305,11 @@ func _test_preview_button(button: TextureButton, path: String) -> void: _expect_true(not state.text.is_empty(), "%s IAP lock/owned state" % path.get_file()) _expect_equal(description.text, tr(hero.description), "%s IAP full description" % path.get_file()) _expect_equal( - IapStore.current_product_id, + Shop.current_product_id, before_product, "%s preview does not start checkout" % path.get_file()) _expect_equal( - IapStore.entitlements, + Shop.entitlements, before_entitlements, "%s IAP entitlement unchanged by preview" % path.get_file()) _expect_equal(Vault.shards, before_shards, "%s shards unchanged by preview" % path.get_file()) @@ -376,8 +376,8 @@ func _test_scroll_range() -> void: func _test_product_artworks() -> void: - var supporter: Control = _product_card(IapStore.SUPPORTER) - var lantern: Control = _product_card(IapStore.LANTERN_COLORS) + var supporter: Control = _product_card(Shop.SUPPORTER) + var lantern: Control = _product_card(Shop.LANTERN_COLORS) _expect_true(supporter != null, "supporter product card") _expect_true(lantern != null, "beacon-color product card") if supporter != null: @@ -416,12 +416,12 @@ func _test_product_artworks() -> void: "%s beacon palette id" % palette_id) _expect_equal( item.self_modulate, - IapStore.PALETTES[palette_id]["light"], + Shop.PALETTES[palette_id]["light"], "%s beacon palette color" % palette_id) await _test_artwork_capture_state( - IapStore.SUPPORTER, "supporter_app_icon", 1) + Shop.SUPPORTER, "supporter_app_icon", 1) await _test_artwork_capture_state( - IapStore.LANTERN_COLORS, "lantern_palette_flames", 4) + Shop.LANTERN_COLORS, "lantern_palette_flames", 4) func _test_artwork_capture_state( @@ -514,9 +514,9 @@ func _test_coin_capture_states() -> void: func _test_target_visual_mutations() -> void: var request: Dictionary = { "kind": "iap_review", - "product_id": IapStore.HERO_DANCER, + "product_id": Shop.HERO_DANCER, } - var card: Control = _product_card(IapStore.HERO_DANCER) + var card: Control = _product_card(Shop.HERO_DANCER) var title: Label = card.get_node("Rows/Title") as Label var portrait: TextureButton = card.get_node( "Rows/Content/HeroPreviews").get_child(0) as TextureButton @@ -660,11 +660,11 @@ func _test_target_visual_mutations() -> void: _expect_true(not bool(state.get("ready", true)), "rejects ready after swapping a hero portrait") portrait.texture_normal = original_texture - request["product_id"] = IapStore.SUPPORTER + request["product_id"] = Shop.SUPPORTER _shop.call("debug_prepare_store_capture", request) await get_tree().process_frame await get_tree().process_frame - card = _product_card(IapStore.SUPPORTER) + card = _product_card(Shop.SUPPORTER) var artwork: Control = card.get_node("Rows/Content/Artwork") as Control var original_artwork_modulate: Color = artwork.modulate artwork.modulate.a = 0.0 @@ -686,7 +686,7 @@ func _test_target_visual_mutations() -> void: func _test_offscreen_capture_rejected() -> void: var request: Dictionary = { "kind": "iap_review", - "product_id": IapStore.HERO_DANCER, + "product_id": Shop.HERO_DANCER, } _shop.call("debug_prepare_store_capture", request) await get_tree().process_frame @@ -798,7 +798,7 @@ func _test_parent_back_priority(button: TextureButton) -> void: func _finish() -> void: Vault.opened.assign(_opened_before) Vault.chosen = _chosen_before - IapStore.entitlements.assign(_entitlements_before) + Shop.entitlements.assign(_entitlements_before) if _failed > 0: printerr("IAP hero-preview test failed — ", _failed, "/", _checked, " case(s)") get_tree().quit(1) diff --git a/apps/game/tests/test_iap_store.gd b/apps/game/tests/test_iap_store.gd index b76a7f7..532902c 100644 --- a/apps/game/tests/test_iap_store.gd +++ b/apps/game/tests/test_iap_store.gd @@ -324,7 +324,11 @@ func _test_native_request_dispatch_semantics() -> void: malformed_store.free() malformed_backend.free() - var native_query: Dictionary = await backend.available_purchases() + # Queries and restore run through the real wrapper's public result API, with + # the fake as its native bridge. Malformed payloads still parse as JSON: + # the wrapper's JSON.parse_string logs an ERROR on text that does not. + var query_backend: Node = GODOT_BACKEND_SCRIPT.new(wrapper) + var native_query: Dictionary = await query_backend.available_purchases() _expect_true( bool(native_query.get("success", false)), "Android result-shaped purchase query distinguishes a normal empty") @@ -332,19 +336,41 @@ func _test_native_request_dispatch_semantics() -> void: native_query.get("purchases", []).size(), 0, "a normal empty query forwards success together with an empty list") + var available_options: Dictionary = plugin.last_available_options + _expect_true( + bool(available_options.get("onlyIncludeActiveItemsIOS", false)), + "current purchase query excludes historical iOS revocations") + plugin.native_available_result_json = JSON.stringify({ + "success": true, + "purchases": [_native_android_purchase( + STORE_SCRIPT.SUPPORTER, "wrapper-android-token")], + }) + native_query = await query_backend.available_purchases() + var android_rows: Array = native_query.get("purchases", []) + _expect_true( + bool(native_query.get("success", false)) and android_rows.size() == 1, + "a valid Android purchase row survives the wrapper's typed conversion") + if android_rows.size() == 1: + var row: Dictionary = android_rows[0] + _expect_equal(row.get("productId", ""), STORE_SCRIPT.SUPPORTER, "typed Android row keeps its product") + _expect_equal(row.get("purchaseToken", ""), "wrapper-android-token", "typed Android row keeps its token") + _expect_equal(row.get("store", ""), "google", "typed Android row names its store") + _expect_equal(row.get("purchaseState", ""), "purchased", "typed Android row keeps its state") + _expect_equal( + row.get("packageNameAndroid", ""), + STORE_SCRIPT.APP_ID, + "typed Android row keeps its package") plugin.native_available_result_json = \ '{"success":false,"purchases":[],"error":"not initialized"}' - native_query = await backend.available_purchases() + native_query = await query_backend.available_purchases() _expect_false( bool(native_query.get("success", false)), "do not treat Android native query failure as a normal empty") + _expect_false(await query_backend.restore(), "Android restore query failure is forwarded") + plugin.native_available_result_json = '["not-a-result"]' + _expect_false(await query_backend.restore(), "Android malformed restore response is fail-closed") plugin.native_available_result_json = '{"success":true,"purchases":[]}' - _expect_true(await backend.restore(), "Android native restore success is forwarded") - plugin.native_restore_result_json = \ - '{"success":false,"error":"billing unavailable"}' - _expect_false(await backend.restore(), "Android native restore error is forwarded") - plugin.native_restore_result_json = "not-json" - _expect_false(await backend.restore(), "Android malformed restore response is fail-closed") + _expect_true(await query_backend.restore(), "Android restore success is forwarded") plugin.native_available_result_json = JSON.stringify({ "success": true, "purchases": [{ @@ -353,44 +379,57 @@ func _test_native_request_dispatch_semantics() -> void: "transactionId": "backend-null-product", }], }) - var malformed_query: Dictionary = await backend.available_purchases() + var malformed_query: Dictionary = await query_backend.available_purchases() _expect_false( bool(malformed_query.get("success", false)), - "adapter must not forward an empty typed purchase object as a normal query") - var available_options: Dictionary = plugin.last_available_options - _expect_true( - bool(available_options.get("onlyIncludeActiveItemsIOS", false)), - "current iOS purchase query excludes historical revocations") + "a malformed purchase row fails the query instead of reading as empty") - plugin._platform = "iOS" + wrapper._platform = "iOS" plugin.ios_available_payload = { "success": true, "purchasesJson": "[]", } - var ios_query: Dictionary = await backend.available_purchases() + var ios_query: Dictionary = await query_backend.available_purchases() _expect_true( bool(ios_query.get("success", false)), "iOS success envelope distinguishes a normal empty") + plugin.ios_available_payload = { + "success": true, + "purchasesJson": JSON.stringify([_native_ios_purchase( + STORE_SCRIPT.LANTERN_COLORS, "wrapper-ios-transaction")]), + } + ios_query = await query_backend.available_purchases() + var ios_rows: Array = ios_query.get("purchases", []) + _expect_true( + bool(ios_query.get("success", false)) and ios_rows.size() == 1, + "a valid iOS purchase row survives the wrapper's typed conversion") + if ios_rows.size() == 1: + var row: Dictionary = ios_rows[0] + _expect_equal(row.get("transactionId", ""), "wrapper-ios-transaction", "typed iOS row keeps its transaction") + _expect_equal(row.get("store", ""), "apple", "typed iOS row names its store") + _expect_equal(row.get("appBundleIdIOS", ""), STORE_SCRIPT.APP_ID, "typed iOS row keeps its bundle") plugin.ios_available_payload = { "success": false, "purchasesJson": "[]", } - ios_query = await backend.available_purchases() + ios_query = await query_backend.available_purchases() _expect_false( bool(ios_query.get("success", false)), "do not treat iOS native query failure as a normal empty") plugin.ios_available_payload = { "success": true, - "purchasesJson": "not-json", + "purchasesJson": '{"not":"a list"}', } - ios_query = await backend.available_purchases() + ios_query = await query_backend.available_purchases() _expect_false( bool(ios_query.get("success", false)), "iOS malformed purchase list is fail-closed") - plugin.ios_restore_success = true - _expect_true(await backend.restore(), "iOS canonical restore success is forwarded") - plugin.ios_restore_success = false - _expect_false(await backend.restore(), "iOS canonical restore failure is forwarded") + plugin.native_restore_result_json = '{"success":true}' + _expect_true(await query_backend.restore(), "iOS restore success is forwarded") + plugin.native_restore_result_json = \ + '{"success":false,"code":"service-error","error":"sync failed"}' + _expect_false(await query_backend.restore(), "iOS restore failure is forwarded") + query_backend.free() backend.free() plugin.free() wrapper.free() @@ -4121,6 +4160,29 @@ func _ios_purchase(product_id: String, transaction_id: String) -> Dictionary: } +## Rows shaped like the native bridge output, which the wrapper validates. +func _native_android_purchase(product_id: String, token: String) -> Dictionary: + var purchase: Dictionary = _android_purchase(product_id, token) + purchase.merge(_native_purchase_fields(token, "google"), true) + return purchase + + +func _native_ios_purchase(product_id: String, transaction_id: String) -> Dictionary: + var purchase: Dictionary = _ios_purchase(product_id, transaction_id) + purchase.merge(_native_purchase_fields(transaction_id, "apple"), true) + return purchase + + +func _native_purchase_fields(id: String, store: String) -> Dictionary: + return { + "id": id, + "store": store, + "transactionDate": 1_785_000_000_000.0, + "quantity": 1, + "isAutoRenewing": false, + } + + func _new_vault() -> Node: var vault: Node = VAULT_SCRIPT.new() vault.load_vault() diff --git a/apps/game/tests/test_title_version.gd b/apps/game/tests/test_title_version.gd index ce705af..447cd09 100644 --- a/apps/game/tests/test_title_version.gd +++ b/apps/game/tests/test_title_version.gd @@ -221,7 +221,7 @@ func _test_title_capture_contract(title: Control, locale: String) -> void: var prompt_player: AnimationPlayer = title.get_node( "Ui/Screen/PromptBlink") as AnimationPlayer var expected_direct_distribution: bool = OS.has_feature("direct_distribution") - var expected_storefront: bool = IapStore.storefront_enabled() + var expected_storefront: bool = Shop.storefront_enabled() _expect_true(prompt_player.is_playing(), locale + " normal title start-hint blink") _expect_equal( prompt_player.current_animation, "blink", diff --git a/apps/game/tools/build_store_graphics.py b/apps/game/tools/build_store_graphics.py index 84f64c8..b908fc8 100644 --- a/apps/game/tools/build_store_graphics.py +++ b/apps/game/tools/build_store_graphics.py @@ -1388,7 +1388,7 @@ def _runtime_fingerprint() -> str: "tests", "tools", } - excluded_files = {"export_presets.cfg", "iapkit.cfg"} + excluded_files = {"export_presets.cfg", "iapkit_publishable.cfg"} files = sorted( path for path in GAME_ROOT.rglob("*") diff --git a/notes/release/iap-store-setup.md b/notes/release/iap-store-setup.md index 7ae5675..b764fbf 100644 --- a/notes/release/iap-store-setup.md +++ b/notes/release/iap-store-setup.md @@ -309,9 +309,10 @@ each console. - [x] The local key is stored in macOS Keychain service `dev.openiap.kit.moonlitbeacon`, account `MoonlitBeacon Mobile`. - [x] Stored as GitHub Actions repository secret `IAPKIT_API_KEY`. -- [x] `res://iapkit.cfg` is created only during Store AAB and iOS export, and - deleted after success or failure. The direct-distribution APK contains - neither the config nor the Billing SDK. +- [x] `res://iapkit_publishable.cfg` is created only during Store AAB and iOS + export, and deleted after success or failure. The direct-distribution + APK contains neither the config nor the Billing SDK. The name is not + `iapkit.cfg` because godot-iap 3.6 drops that file from release exports. - [x] IAPKit iOS settings include Bundle ID, App Apple ID, Issuer ID, In-App Purchase Key ID and `.p8` for purchase verification, and App Store Connect API Key ID and `.p8` for product sync. @@ -496,6 +497,13 @@ the publishable key. ## Release verdict +Status on 2026-09-29, from the public store pages: the App Store listing +(version 2.1.0) shows all 10 sale items under In-App Purchases with prices, +and the Google Play listing is marked "In-app purchases". Real sales are +live, yet no box below was ticked in this file. +TODO(maintainer): tick the items that were verified before launch and note +any that are still open. + If any item below is empty, hold a real IAP release. - [ ] Confirmed success and cancel of the 10 sale SKUs in Android internal diff --git a/notes/release/store-page.md b/notes/release/store-page.md index a106e96..ac0a33b 100644 --- a/notes/release/store-page.md +++ b/notes/release/store-page.md @@ -493,7 +493,7 @@ Art and UI Moonlit Beacon — Original assets Music, sound Ninja Adventure Asset Pack — Pixel-Boy and AAA — CC0 Kenney UI Audio — Kenney — CC0 Font Maplestory — ⓒ NEXON Korea; Noto Sans CJK SC — Google — SIL Open Font License 1.1 -Store SDK godot-iap 3.5.1 — OpenIAP contributors — MIT License +Store SDK godot-iap 3.6.1 — OpenIAP contributors — MIT License Made by Hyo Dev Source and course: https://github.com/hyodotdev/MoonlitBeacon diff --git a/scripts/android-build.mjs b/scripts/android-build.mjs index 30f3814..34ea627 100644 --- a/scripts/android-build.mjs +++ b/scripts/android-build.mjs @@ -61,7 +61,7 @@ const gradleWrapper = join( ); const iapKitGradleConfig = join( gradleProject, - 'assetPackInstallTime/src/main/assets/iapkit.cfg', + 'assetPackInstallTime/src/main/assets/iapkit_publishable.cfg', ); const args = process.argv.slice(2); const { bundle, debug, release } = parseAndroidBuildFlags(args); @@ -336,7 +336,7 @@ try { // Alternating direct APK and Play AAB on the same Gradle template can leave the previous // channel's manifest and DEX in incremental output, so clean before every export. if (existsSync(templateMarker)) { - // Play AAB pulls openiap-google:3.5.2 / androidx.core 1.18.0. + // Play AAB pulls openiap-google:3.6.1 / androidx.core 1.18.0. // Godot 4.7.1's template still pins AGP 8.6.1; bump it to 8.9.1. ensureGodotAgpCompatibleAndroidx(gradleProject); } diff --git a/scripts/capture-android-tablet-evidence.mjs b/scripts/capture-android-tablet-evidence.mjs index e16cc1c..f0cc1bd 100644 --- a/scripts/capture-android-tablet-evidence.mjs +++ b/scripts/capture-android-tablet-evidence.mjs @@ -304,7 +304,7 @@ function sourceInputSha256() { function runtimeSha256() { const gameRoot = join(REPO_ROOT, 'apps/game'); const excludedRoots = new Set(['.godot', 'android', 'docs', 'ios', 'tests', 'tools']); - const excludedFiles = new Set(['export_presets.cfg', 'iapkit.cfg']); + const excludedFiles = new Set(['export_presets.cfg', 'iapkit_publishable.cfg']); const files = []; function visit(directory, relativeDirectory = '') { for (const entry of readdirSync(directory, { withFileTypes: true })) { diff --git a/scripts/capture-ios-device-evidence.mjs b/scripts/capture-ios-device-evidence.mjs index 8165a7a..fe53a0f 100644 --- a/scripts/capture-ios-device-evidence.mjs +++ b/scripts/capture-ios-device-evidence.mjs @@ -345,7 +345,7 @@ function writeJson(path, value) { function runtimeSha256() { const gameRoot = join(REPO_ROOT, 'apps/game'); const excludedRoots = new Set(['.godot', 'android', 'docs', 'ios', 'tests', 'tools']); - const excludedFiles = new Set(['export_presets.cfg', 'iapkit.cfg']); + const excludedFiles = new Set(['export_presets.cfg', 'iapkit_publishable.cfg']); const files = []; function visit(directory, relativeDirectory = '') { for (const entry of readdirSync(directory, { withFileTypes: true })) { diff --git a/scripts/capture-store-screenshots.mjs b/scripts/capture-store-screenshots.mjs index 4556825..d600a37 100644 --- a/scripts/capture-store-screenshots.mjs +++ b/scripts/capture-store-screenshots.mjs @@ -611,7 +611,7 @@ function runtimeFingerprint() { 'tests', 'tools', ]); - const excludedFiles = new Set(['export_presets.cfg', 'iapkit.cfg']); + const excludedFiles = new Set(['export_presets.cfg', 'iapkit_publishable.cfg']); const files = []; function visit(directory, relativeDirectory = '') { diff --git a/scripts/check-store-metadata.mjs b/scripts/check-store-metadata.mjs index a135ad8..25e84f2 100644 --- a/scripts/check-store-metadata.mjs +++ b/scripts/check-store-metadata.mjs @@ -49,7 +49,7 @@ const PRODUCTS = [ "com.crossplatformkorea.moonlitbeacon.continue_coin_10", ]; -// Must be the same list as the app's `IapStore.CONSUMABLE_PRODUCT_IDS`. +// Must be the same list as the app's `Shop.CONSUMABLE_PRODUCT_IDS`. const CONSUMABLE_PRODUCTS = new Set([ "com.crossplatformkorea.moonlitbeacon.continue_coin", "com.crossplatformkorea.moonlitbeacon.continue_coin_5", diff --git a/scripts/ios.mjs b/scripts/ios.mjs index 3aae5d8..a9ea169 100644 --- a/scripts/ios.mjs +++ b/scripts/ios.mjs @@ -93,6 +93,7 @@ import { redactSensitiveValues, readAppStoreCredentials, readIosReleaseMetadata, + resolveAppleTeamId, runExclusiveIosWorkflow, runWithStableReleaseSources, xcodebuildAuthenticationArguments, @@ -103,7 +104,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const SCHEME = 'MoonlitBeacon'; // = export path basename; Godot chooses that name. const BUNDLE = 'com.crossplatformkorea.moonlitbeacon'; const ISOLATED_CAPTURE_BUNDLE = `${BUNDLE}.storecapture`; -const TEAM = 'PRDQGB267K'; +const TEAM = resolveAppleTeamId(); const DEVICE_DISCOVERY_TIMEOUT_MS = 15 * 1000; const DEVICE_INFO_TIMEOUT_MS = 30 * 1000; const DEVICE_INSTALL_TIMEOUT_MS = 2 * 60 * 1000; @@ -906,10 +907,15 @@ function readOptionalPlistValue(plist, keyPath, format) { } function expectedIosReleaseMetadata() { - const metadata = readIosReleaseMetadata( - readFileSync(join(ROOT, 'apps/game/project.godot'), 'utf8'), - readFileSync(join(ROOT, 'apps/game/export_presets.cfg'), 'utf8'), - ); + // Godot writes the preset's team into the Xcode project, but every + // xcodebuild call passes DEVELOPMENT_TEAM=TEAM, so the archive carries TEAM. + const metadata = { + ...readIosReleaseMetadata( + readFileSync(join(ROOT, 'apps/game/project.godot'), 'utf8'), + readFileSync(join(ROOT, 'apps/game/export_presets.cfg'), 'utf8'), + ), + teamId: TEAM, + }; assertIosReleaseMetadata(metadata, { expectedBundleId: BUNDLE, expectedTeamId: TEAM, diff --git a/scripts/lib/android-build.mjs b/scripts/lib/android-build.mjs index 4d7facb..a67550e 100644 --- a/scripts/lib/android-build.mjs +++ b/scripts/lib/android-build.mjs @@ -642,7 +642,7 @@ export function verifyAndroidArchiveResourceBoundary( function androidArchiveIapKitEntries(listing) { return String(listing) .split(/\r?\n/) - .filter((entry) => /(?:^|\/)iapkit\.cfg$/.test(entry)); + .filter((entry) => /(?:^|\/)iapkit_publishable\.cfg$/.test(entry)); } function androidArchiveHasSafeIapKitConfig(contents) { diff --git a/scripts/lib/android-build.test.mjs b/scripts/lib/android-build.test.mjs index cc7ac81..aa53b5a 100644 --- a/scripts/lib/android-build.test.mjs +++ b/scripts/lib/android-build.test.mjs @@ -575,14 +575,14 @@ test('verifies IAPKit config count and pk-only contents per Android distribution if (command === 'jar') { return { status: 0, - stdout: 'assetPackInstallTime/assets/iapkit.cfg\n', + stdout: 'assetPackInstallTime/assets/iapkit_publishable.cfg\n', }; } assert.equal(command, 'unzip'); assert.deepEqual(args, [ '-p', '/tmp/release.aab', - 'assetPackInstallTime/assets/iapkit.cfg', + 'assetPackInstallTime/assets/iapkit_publishable.cfg', ]); return { status: 0, @@ -606,7 +606,7 @@ test('verifies IAPKit config count and pk-only contents per Android distribution ); assert.throws( () => verifyAndroidArchiveIapBoundary('/tmp/direct.apk', { - spawn: () => ({ status: 0, stdout: 'assets/iapkit.cfg\n' }), + spawn: () => ({ status: 0, stdout: 'assets/iapkit_publishable.cfg\n' }), store: false, }), /Direct-distribution/, @@ -616,7 +616,7 @@ test('verifies IAPKit config count and pk-only contents per Android distribution spawn: (command) => command === 'jar' ? { status: 0, - stdout: 'assetPackInstallTime/assets/iapkit.cfg\n', + stdout: 'assetPackInstallTime/assets/iapkit_publishable.cfg\n', } : { status: 0, @@ -632,7 +632,7 @@ test('verifies IAPKit config count and pk-only contents per Android distribution spawn: (command) => command === 'jar' ? { status: 0, - stdout: 'assetPackInstallTime/assets/iapkit.cfg\n', + stdout: 'assetPackInstallTime/assets/iapkit_publishable.cfg\n', } : { status: 0, diff --git a/scripts/lib/android-gradle-compat.mjs b/scripts/lib/android-gradle-compat.mjs index 4a054f8..c14955d 100644 --- a/scripts/lib/android-gradle-compat.mjs +++ b/scripts/lib/android-gradle-compat.mjs @@ -2,8 +2,8 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; // Godot 4.7.1's Android gradle template pins AGP 8.6.1 and compileSdk 36. -// AGP 8.6.1 is only tested through compileSdk 35. openiap-google:3.5.2 -// (godot-iap 3.5.1) depends on androidx.core:1.18.0, which needs AGP 8.9.1. +// AGP 8.6.1 is only tested through compileSdk 35. openiap-google:3.6.1 +// (godot-iap 3.6.1) depends on androidx.core:1.18.0, which needs AGP 8.9.1. // The template already ships Gradle 8.11.1, which is the 8.9.1 minimum. export const GODOT_STOCK_ANDROID_GRADLE_PLUGIN = '8.6.1'; export const GODOT_COMPAT_ANDROID_GRADLE_PLUGIN = '8.9.1'; diff --git a/scripts/lib/app-store-release.mjs b/scripts/lib/app-store-release.mjs index dd8c76f..0b4ad8a 100644 --- a/scripts/lib/app-store-release.mjs +++ b/scripts/lib/app-store-release.mjs @@ -27,6 +27,7 @@ import { import { inflateSync } from 'node:zlib'; import { assertIosReleaseMetadata, + DEFAULT_APPLE_TEAM_ID, readAppStoreCredentials, readIosReleaseMetadata, } from './ios-distribution.mjs'; @@ -210,7 +211,6 @@ export const APP_STORE_SCREENSHOT_PROVENANCE_RELATIVE_PATH = export const APP_STORE_CAPTURE_REPORT_RELATIVE_PATH = 'builds/shots/store-localized/capture-report.json'; const APP_STORE_BUNDLE_ID = 'com.crossplatformkorea.moonlitbeacon'; -const APP_STORE_TEAM_ID = 'PRDQGB267K'; const SCREENSHOT_PROVENANCE_CONTRACT = 'moonlit-app-store-screenshot-provenance-v1'; const SCREENSHOT_PROVENANCE_SCHEMA_VERSION = 1; @@ -1230,7 +1230,7 @@ export function buildAppStoreReleasePayload({ ); assertIosReleaseMetadata(releaseMetadata, { expectedBundleId: APP_STORE_BUNDLE_ID, - expectedTeamId: APP_STORE_TEAM_ID, + expectedTeamId: DEFAULT_APPLE_TEAM_ID, }); const storeVersion = parseVersion(markdown); if (storeVersion !== releaseMetadata.shortVersion) { diff --git a/scripts/lib/capture-run-state.test.mjs b/scripts/lib/capture-run-state.test.mjs index 4bb1110..cf5b23c 100644 --- a/scripts/lib/capture-run-state.test.mjs +++ b/scripts/lib/capture-run-state.test.mjs @@ -2463,7 +2463,7 @@ test('runtime probe and per-screen debug state are not enabled in release', () = const shopPrepareEnd = shop.indexOf('func debug_store_capture_state(', shopPrepareStart); const shopPrepare = shop.slice(shopPrepareStart, shopPrepareEnd); assert.match(shopPrepare, /_capture_selected_product_id = product_id/u); - assert.match(shopPrepare, /_coins_scroll[\s\S]+IapStore\.is_consumable/u); + assert.match(shopPrepare, /_coins_scroll[\s\S]+Shop\.is_consumable/u); assert.match(shopPrepare, /scroll\.scroll_horizontal =/u); assert.equal( /(?:price|action|_restore|_status)\.(?:text|disabled)\s*=/u.test(shopPrepare), diff --git a/scripts/lib/godot-iap-vendor.test.mjs b/scripts/lib/godot-iap-vendor.test.mjs index f6ed346..a7835f0 100644 --- a/scripts/lib/godot-iap-vendor.test.mjs +++ b/scripts/lib/godot-iap-vendor.test.mjs @@ -14,7 +14,7 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); -const GODOT_IAP_VERSION = '3.5.1'; +const GODOT_IAP_VERSION = '3.6.1'; function readRepoFile(path) { return readFileSync(join(REPO_ROOT, path)); @@ -41,7 +41,7 @@ function assertPinnedFile({ contents, marker, path, expectedSha }) { assert.equal(sha256(path), expectedSha, `${path} drifted from its pinned SHA-256`); } -test('godot-iap plugin version stays pinned to 3.5.1', () => { +test(`godot-iap plugin version stays pinned to ${GODOT_IAP_VERSION}`, () => { const pluginConfig = readRepoFile( 'apps/game/addons/godot-iap/plugin.cfg', ).toString('utf8'); @@ -72,32 +72,39 @@ test('Android godot-iap AARs match the documented official binaries', () => { } }); -test('Moonlit GDScript integration reverses exactly to official 3.5.1', (t) => { +test(`Moonlit GDScript integration reverses exactly to official ${GODOT_IAP_VERSION}`, (t) => { const readme = readRepoFile('vendor/godot-iap/README.md').toString('utf8'); const patchPath = 'vendor/godot-iap/0001-moonlit-integration.patch'; - const patchSha = 'afeb152265e5431233930237e5d4edc9c2042d9dfec00b4e0f7c09d251a649da'; + const patchSha = '84839a7e1141dbf2b163c2b197c5e9c2f868ffacdecef4347c59c8666874c667'; const files = [ { path: 'apps/game/addons/godot-iap/godot_iap.gd', - officialSha: 'd872fd1d34c9075b761ca6ba2ca90c8d513b8e7d8e94f1e9a96f3b9cf9e7c2cb', - moonlitSha: '1bf74d123066a932b1cb7baf8fb3eb5c95c6d1166ff8bc6dfa923b0058bb2238', + officialSha: '2bf54bbf119886a607ea1deb2897e9d28de27ca01c7b4f7ca6daebe3a1188bfe', + moonlitSha: 'e31fdf59e230b7f677c2513a8f6323240a794382ddedf92cde771a93cf3c899d', }, { path: 'apps/game/addons/godot-iap/godot_iap_plugin.gd', - officialSha: 'e0eb26b046700c2de24c7800927e0444ecec46e9eeb474c0d3476d865cb0fba0', - moonlitSha: 'a3eaf879fb9f7b017133e2bffa52dc3e39fae5b44fb7332dbf047986a1c4a50b', - }, - { - path: 'apps/game/addons/godot-iap/scripts/fix_ios_embed.sh', - officialSha: '2b6494b705ce1beb7e23e0e5cfc8d60eff852ea3b6ffb1c72579def15f041807', - moonlitSha: '06e1f319396196146fcfa7db967e5a82cca4d2011ba5b2ccb6fd51daf2fca249', + officialSha: '31500d82ee2ed4b78e42fcbc1dd8c28ad5d97419df18b29a72b08bd83bea9ab8', + moonlitSha: '47ec04b62c99d99addd6e76636143007edd944cc3a6229dd416c92993b9899a6', }, ]; - - assert.match(readme, /godot-iap-3\.5\.1/); - assert.match(readme, /acb7924d6512cdf87a6a51d22bf75cb285f76dc9/); - assert.match(readme, /types\.gd.*not.*reverse-patch/is); - assert.match(readme, /var store := IapStore\.UNKNOWN/); + const unmodified = { + 'types.gd': 'd14c4b108c8af4f5b0b42203706fe83ada10d173e6c862c18e77bb2d69fc7970', + 'android_store.gd': '1b9d4b80e70c2a93811c0fe973aab21b4f2149f0d391f2ca6971ecb603f43ffd', + 'scripts/fix_ios_embed.sh': '1606332b07dcf2aa2b77ae266e476414a62423ce8769368122a52672002d938e', + 'android/GodotIap.gdap': 'b776033d531bb439ca85c69d079f4ca0f82ad2cacd35065318248349492ac806', + }; + + assert.match(readme, /godot-iap-3\.6\.1/); + assert.match(readme, /c1a3658e12a0bb7de6fdcfb40f0d82df9f68a653/); + for (const [file, expectedSha] of Object.entries(unmodified)) { + assertPinnedFile({ + contents: readme, + marker: `- \`${file}\`:`, + path: `apps/game/addons/godot-iap/${file}`, + expectedSha, + }); + } assert.equal(documentedSha(readme, '- patch SHA-256:'), patchSha); assert.equal(sha256(patchPath), patchSha, `${patchPath} drifted`); @@ -131,7 +138,7 @@ test('Moonlit GDScript integration reverses exactly to official 3.5.1', (t) => { assert.equal( sha256Absolute(join(stagingRoot, file.path)), file.officialSha, - `${file.path} does not reverse to official 3.5.1`, + `${file.path} does not reverse to official ${GODOT_IAP_VERSION}`, ); } }); diff --git a/scripts/lib/iapkit-config.mjs b/scripts/lib/iapkit-config.mjs index 06e417a..2b3553b 100644 --- a/scripts/lib/iapkit-config.mjs +++ b/scripts/lib/iapkit-config.mjs @@ -17,8 +17,8 @@ const PUBLISHABLE_KEY = /^openiap-kit_pk_[A-Za-z0-9_-]{32,}$/; export function iapKitConfigPaths(root) { return { - config: join(root, 'apps/game/iapkit.cfg'), - directStage: join(root, 'builds/.iapkit.cfg-direct-stage'), + config: join(root, 'apps/game/iapkit_publishable.cfg'), + directStage: join(root, 'builds/.iapkit_publishable.cfg-direct-stage'), }; } diff --git a/scripts/lib/iapkit-config.test.mjs b/scripts/lib/iapkit-config.test.mjs index 44f285c..082b1e3 100644 --- a/scripts/lib/iapkit-config.test.mjs +++ b/scripts/lib/iapkit-config.test.mjs @@ -161,7 +161,7 @@ test('safely cleans only generated config copied into the Gradle template', () = }); const copied = join( root, - 'apps/game/android/build/assetPackInstallTime/src/main/assets/iapkit.cfg', + 'apps/game/android/build/assetPackInstallTime/src/main/assets/iapkit_publishable.cfg', ); mkdirSync(dirname(copied), { recursive: true }); writeFileSync(copied, readFileSync(generated, 'utf8')); @@ -188,6 +188,6 @@ test('includes generated IAPKit config only in the store preset export', () => { value.includes('name="Android Play"\n')); assert.match(androidDirect, /include_filter="firebase\.cfg"/); - assert.match(ios, /include_filter="iapkit\.cfg,firebase\.cfg"/); - assert.match(androidPlay, /include_filter="iapkit\.cfg,firebase\.cfg"/); + assert.match(ios, /include_filter="iapkit_publishable\.cfg,firebase\.cfg"/); + assert.match(androidPlay, /include_filter="iapkit_publishable\.cfg,firebase\.cfg"/); }); diff --git a/scripts/lib/ios-distribution.mjs b/scripts/lib/ios-distribution.mjs index 32e9eba..99f1fe5 100644 --- a/scripts/lib/ios-distribution.mjs +++ b/scripts/lib/ios-distribution.mjs @@ -18,6 +18,20 @@ export const APP_STORE_CREDENTIAL_ENV = Object.freeze({ issuerId: 'MOONLIT_ASC_ISSUER_ID', privateKeyPath: 'MOONLIT_ASC_PRIVATE_KEY', }); +// The team that publishes Moonlit Beacon; a copy of the project sets +// MOONLIT_APPLE_TEAM_ID to sign with its own team. +export const DEFAULT_APPLE_TEAM_ID = 'PRDQGB267K'; + +export function resolveAppleTeamId(env = process.env) { + const value = typeof env.MOONLIT_APPLE_TEAM_ID === 'string' + ? env.MOONLIT_APPLE_TEAM_ID.trim() + : ''; + if (value === '') return DEFAULT_APPLE_TEAM_ID; + if (!/^[A-Z0-9]{10}$/.test(value)) { + throw new Error('MOONLIT_APPLE_TEAM_ID must be a 10-character Apple Team ID.'); + } + return value; +} export const IOS_EXCLUSIVE_WORKFLOW_COMMANDS = Object.freeze([ 'export', 'build', diff --git a/scripts/lib/ios-distribution.test.mjs b/scripts/lib/ios-distribution.test.mjs index 218f31c..c94841b 100644 --- a/scripts/lib/ios-distribution.test.mjs +++ b/scripts/lib/ios-distribution.test.mjs @@ -4,6 +4,7 @@ import { chmodSync, mkdirSync, mkdtempSync, + readFileSync, rmSync, symlinkSync, utimesSync, @@ -12,6 +13,7 @@ import { import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { APP_STORE_CREDENTIAL_ENV, altoolAuthenticationArguments, @@ -30,6 +32,7 @@ import { assertSafeIpaEntries, assertSigningCertificateValid, codeSigningCertificateArguments, + DEFAULT_APPLE_TEAM_ID, extractPlistDataValues, iosCommandNeedsExclusiveWorkflow, IOS_RELEASE_BUILD_CHAIN_INPUTS, @@ -38,6 +41,7 @@ import { redactSensitiveValues, readAppStoreCredentials, readIosReleaseMetadata, + resolveAppleTeamId, runExclusiveIosWorkflow, runWithStableReleaseSources, xcodebuildAuthenticationArguments, @@ -844,3 +848,26 @@ test('iOS release freshness check is serialized with Android export temporary so }); assert.deepEqual(events, ['nested-scan']); }); + +test('Apple team comes from MOONLIT_APPLE_TEAM_ID and defaults to the preset team', () => { + assert.equal(resolveAppleTeamId({}), DEFAULT_APPLE_TEAM_ID); + assert.equal(resolveAppleTeamId({ MOONLIT_APPLE_TEAM_ID: ' ' }), DEFAULT_APPLE_TEAM_ID); + assert.equal( + resolveAppleTeamId({ MOONLIT_APPLE_TEAM_ID: ' ABCDE12345 ' }), + 'ABCDE12345', + ); + for (const invalid of ['abcde12345', 'ABCDE1234', 'ABCDE123456', 'ABCDE-1234']) { + assert.throws( + () => resolveAppleTeamId({ MOONLIT_APPLE_TEAM_ID: invalid }), + /MOONLIT_APPLE_TEAM_ID/, + ); + } + const presets = readFileSync( + fileURLToPath(new URL('../../apps/game/export_presets.cfg', import.meta.url)), + 'utf8', + ); + assert.ok( + presets.includes(`application/app_store_team_id="${DEFAULT_APPLE_TEAM_ID}"`), + 'the iOS preset and DEFAULT_APPLE_TEAM_ID name the same team', + ); +}); diff --git a/vendor/godot-iap-android/README.md b/vendor/godot-iap-android/README.md index 00b5569..b9ea357 100644 --- a/vendor/godot-iap-android/README.md +++ b/vendor/godot-iap-android/README.md @@ -2,28 +2,33 @@ `apps/game/addons/godot-iap/android/GodotIap.{debug,release}.aar` are taken byte-for-byte from the official -[`godot-iap-3.5.1`](https://github.com/hyodotdev/openiap/releases/tag/godot-iap-3.5.1) -release ZIP. +[`godot-iap-3.6.1`](https://github.com/hyodotdev/openiap/releases/tag/godot-iap-3.6.1) +release ZIP; 3.6.1 ships the same bytes as 3.5.1. -- upstream tag commit: `acb7924d6512cdf87a6a51d22bf75cb285f76dc9` -- official release ZIP SHA-256: `30ec3a2e149644b3fa6c51f2d6f66c180e024388b317ff6ed35000bb88ab6c7c` +- upstream tag commit: `c1a3658e12a0bb7de6fdcfb40f0d82df9f68a653` +- official release ZIP SHA-256: `6aa51a9c4d195e9537b42ec4f4ecbbf637e254442aa2641d415246f3e8dc7cf9` -Verified SHA-256 — official 3.5.1 debug and release AARs are identical: +Verified SHA-256 — official 3.6.1 debug and release AARs are identical: - official debug AAR: `3b19b3f8d9839b6e1596b3fbdf7a262ed54acda5983a43e17a85cb142aa4e34a` - official release AAR: `3b19b3f8d9839b6e1596b3fbdf7a262ed54acda5983a43e17a85cb142aa4e34a` -`GodotIap.gdap` remote dependencies ship with 3.5.1 as -`io.github.hyochan.openiap:openiap-google:3.5.2`. +`GodotIap.gdap` remote dependencies ship with 3.6.1 as +`io.github.hyochan.openiap:openiap-google:3.6.1`. + +godot-iap 3.6 adds the `openiap/android_store` export option. Its default, +`auto`, links the store of the one connected device in a debug export, so the +`Android Play` preset pins `play`. Play AAB export bumps the generated Godot Gradle template from AGP 8.6.1 to -8.9.1. `openiap-google:3.5.2` depends on `androidx.core:1.18.0`, which needs +8.9.1. `openiap-google:3.6.1` depends on `androidx.core:1.18.0`, which needs that plugin version, and Godot 4.7.1 already ships Gradle 8.11.1. Direct-distribution APKs do not pull the Maven artifact. -`apps/game/scripts/iap/godot_iap_3_compat.gd` still reads the result envelope -and the Android `restorePurchases()` result. Purchase requests go through the -official 3.x `request_purchase()`. A normal async `null` is allowed; only a +Purchase queries and restore go through the public +`get_available_purchases_result()` and `restore_purchases()`, so a failed query +is never read as an empty list. Purchase requests go through the official 3.x +`request_purchase()`. A normal async `null` is allowed; only a synchronous `purchase_error` during the call counts as dispatch failure. The license is MIT, kept at `apps/game/addons/godot-iap/LICENSE`. diff --git a/vendor/godot-iap-ios/README.md b/vendor/godot-iap-ios/README.md index 8792ca7..99602f8 100644 --- a/vendor/godot-iap-ios/README.md +++ b/vendor/godot-iap-ios/README.md @@ -1,11 +1,11 @@ # godot-iap iOS runtime iOS frameworks and the descriptor come from the official -`godot-iap-3.5.1` release. All three files match the official ZIP -byte-for-byte. +`godot-iap-3.6.1` release. All three files match the official ZIP +byte-for-byte; 3.6.1 ships the same bytes as 3.5.1. -- release: -- ZIP SHA-256: `30ec3a2e149644b3fa6c51f2d6f66c180e024388b317ff6ed35000bb88ab6c7c` +- release: +- ZIP SHA-256: `6aa51a9c4d195e9537b42ec4f4ecbbf637e254442aa2641d415246f3e8dc7cf9` - license: MIT (`LICENSE`) Verified stored-file SHA-256: @@ -18,19 +18,15 @@ Verified stored-file SHA-256: - official `SwiftGodotRuntime.framework/SwiftGodotRuntime`: `89a1f9306992f6b83be0432c9bdec83454d6cf1393e19b6f4c2b2422bd9253b3` -Leaving the iOS-only `.gdextension` live under `res://` makes the desktop -editor look for a host-platform library and error. The official 3.5.1 -descriptor already uses `include_tags = ["ios"]` and only declares the iOS -framework, so the contents stay unmodified and only the `.ios` suffix is -added. `scripts/ios.mjs` copies this `bin/` into -`apps/game/addons/godot-iap/bin/` for the iOS export window, and the export -plugin restores the live `.gdextension` name plus the extension list inside -the PCK. After the frameworks are added to Xcode Embed Frameworks, the -temporary `bin/` is removed again. +The descriptor declares only the iOS framework, so a live `.gdextension` +under `res://` makes desktop Godot 4.7 log errors on every run. The contents +stay official; only the `.ios` suffix is added. -The GDScript wrapper is official 3.5.1 plus the Moonlit patch documented -in [`../godot-iap/README.md`](../godot-iap/README.md). Generic async calls keep -the 30-second limit; Moonlit Beacon patches only the 10-minute restore limit -and the direct-distribution guard. iOS query success envelopes are still read -by `apps/game/scripts/iap/godot_iap_3_compat.gd` so an authoritative empty -list is not confused with a native failure. +Every `pnpm ios:*` export (`scripts/ios.mjs`) copies this `bin/` into +`apps/game/addons/godot-iap/bin/` for the length of the export. The export +plugin writes the descriptor into the PCK under the live `.gdextension` name, +`fix_ios_embed.sh` adds both frameworks to Xcode's Embed Frameworks and prints +its `Runtime embed check` line, and the temporary `bin/` is removed again. + +The GDScript wrapper is official 3.6.1 plus the patch documented in +[`../godot-iap/README.md`](../godot-iap/README.md). diff --git a/vendor/godot-iap/0001-moonlit-integration.patch b/vendor/godot-iap/0001-moonlit-integration.patch index d2e88ea..c8b8937 100644 --- a/vendor/godot-iap/0001-moonlit-integration.patch +++ b/vendor/godot-iap/0001-moonlit-integration.patch @@ -1,36 +1,23 @@ diff --git a/apps/game/addons/godot-iap/godot_iap.gd b/apps/game/addons/godot-iap/godot_iap.gd -index bdd5ed0..91c1b09 100644 +index 6c63de9..521e5d4 100644 --- a/apps/game/addons/godot-iap/godot_iap.gd +++ b/apps/game/addons/godot-iap/godot_iap.gd -@@ -72,7 +72,11 @@ var _apple_async_terminal_keys: Dictionary = {} +@@ -73,7 +73,9 @@ var _apple_async_terminal_keys: Dictionary = {} var _apple_async_terminal_order: Array[String] = [] var _apple_async_cancellation_generation := 0 var _apple_async_timeout_seconds := 30.0 -var _apple_async_restore_timeout_seconds := 120.0 -+# AppStore.sync() can present Face ID, password, or account sheets. -+# Failing an explicit restore at 120s while native auth is still running -+# leaves the app UI failed and the system prompt alive. Moonlit Beacon -+# keeps the verified 10-minute restore limit. ++# Moonlit: AppStore.sync() can show Face ID or sign-in sheets that outlast ++# 120 s, so an explicit restore waits up to 10 minutes. +var _apple_async_restore_timeout_seconds := 600.0 var _apple_async_ui_timeout_seconds := 300.0 # Platform detection -@@ -84,6 +88,10 @@ func _is_apple() -> bool: - - - func _ready() -> void: -+ # Direct-distribution APKs (itch.io) ship without a native store SDK. -+ # Keep the same autoload, but skip singleton lookup and error logs. -+ if OS.has_feature("direct_distribution"): -+ return - if _is_initialized: - return - _is_initialized = true diff --git a/apps/game/addons/godot-iap/godot_iap_plugin.gd b/apps/game/addons/godot-iap/godot_iap_plugin.gd -index 35502be..d8fb762 100644 +index f6bae9e..9e06100 100644 --- a/apps/game/addons/godot-iap/godot_iap_plugin.gd +++ b/apps/game/addons/godot-iap/godot_iap_plugin.gd -@@ -34,6 +34,10 @@ class GodotIapExportPlugin extends EditorExportPlugin: +@@ -40,6 +40,10 @@ class GodotIapExportPlugin extends EditorExportPlugin: "res://addons/godot-iap/bin/ios/GodotIap.framework", "res://addons/godot-iap/bin/ios/SwiftGodotRuntime.framework", ] @@ -41,14 +28,12 @@ index 35502be..d8fb762 100644 func _get_name() -> String: return PLUGIN_NAME -@@ -49,6 +53,24 @@ class GodotIapExportPlugin extends EditorExportPlugin: +@@ -59,6 +63,22 @@ class GodotIapExportPlugin extends EditorExportPlugin: if not _is_ios_export(features): return -+ # Keeping the iOS-only descriptor under the live `.gdextension` name -+ # makes the desktop editor hunt for a host-platform library and ERROR. -+ # Export still embeds the original name plus the extension list, so iOS -+ # runtime behavior stays the same and day-to-day editing stays quiet. ++ # Moonlit keeps the iOS-only descriptor as .gdextension.ios so desktop ++ # Godot 4.7 logs no errors; the PCK still gets it under the live name. + if FileAccess.file_exists(IOS_GDEXTENSION_SOURCE): + add_file( + IOS_GDEXTENSION_EXPORT, @@ -66,55 +51,3 @@ index 35502be..d8fb762 100644 for framework_path in IOS_FRAMEWORKS: if not DirAccess.dir_exists_absolute(framework_path): push_warning("[GodotIap] Missing iOS framework: %s" % framework_path) -diff --git a/apps/game/addons/godot-iap/scripts/fix_ios_embed.sh b/apps/game/addons/godot-iap/scripts/fix_ios_embed.sh -index d1bf53a..ef234a2 100755 ---- a/apps/game/addons/godot-iap/scripts/fix_ios_embed.sh -+++ b/apps/game/addons/godot-iap/scripts/fix_ios_embed.sh -@@ -77,8 +77,24 @@ copy_framework_plists - # Backup original once per run. - cp "$PBXPROJ" "$PBXPROJ.backup" - -+# Homebrew framework Python under Node spawnSync can stall loading -+# extension modules on some macOS setups. The Xcode-bundled system -+# Python has the stdlib this script needs, so prefer it. -+PYTHON_BIN="${PYTHON_BIN:-}" -+if [ -z "$PYTHON_BIN" ]; then -+ if [ "$(uname -s)" = "Darwin" ] && [ -x /usr/bin/python3 ]; then -+ PYTHON_BIN=/usr/bin/python3 -+ else -+ PYTHON_BIN="$(command -v python3 || true)" -+ fi -+fi -+if [ -z "$PYTHON_BIN" ] || [ ! -x "$PYTHON_BIN" ]; then -+ echo "Error: python3 is required to fix iOS framework embedding." >&2 -+ exit 1 -+fi -+ - export PBXPROJ IOS_EXPORT_DIR --python3 <<'PY' -+"$PYTHON_BIN" <<'PY' - import hashlib - import os - import plistlib -@@ -147,13 +163,15 @@ for framework in frameworks: - missing = [framework for framework in frameworks if framework not in framework_refs] - if missing: - print( -- "Warning: framework references not found in Xcode project: " -- + ", ".join(f"{framework}.framework" for framework in missing) -+ "Error: framework references not found in Xcode project: " -+ + ", ".join(f"{framework}.framework" for framework in missing), -+ file=sys.stderr, -+ ) -+ print( -+ "Enable the GodotIap plugin in the iOS export preset and export again.", -+ file=sys.stderr, - ) -- print("Enable the GodotIap plugin in the iOS export preset and export again.") -- with open(pbxproj, "w", encoding="utf-8") as file: -- file.write(content) -- sys.exit(0) -+ sys.exit(1) - - embed_phase_match = re.search( - r"([A-F0-9]{24}|\w+)\s*/\*\s*Embed Frameworks\s*\*/\s*=\s*\{.*?isa\s*=\s*PBXCopyFilesBuildPhase;.*?\n\t\t\};", diff --git a/vendor/godot-iap/README.md b/vendor/godot-iap/README.md index 4ec44a1..2f16b34 100644 --- a/vendor/godot-iap/README.md +++ b/vendor/godot-iap/README.md @@ -1,38 +1,69 @@ -# godot-iap 3.5.1 Moonlit integration patch +# godot-iap 3.6.1 Moonlit integration patch -GDScript integration under `apps/game/addons/godot-iap/` starts from the official -[`godot-iap-3.5.1`](https://github.com/hyodotdev/openiap/releases/tag/godot-iap-3.5.1) -release and applies only Moonlit Beacon's verified safety boundaries. +`apps/game/addons/godot-iap/` is the official +[`godot-iap-3.6.1`](https://github.com/hyodotdev/openiap/releases/tag/godot-iap-3.6.1) +release plus two small patches. -- upstream tag commit: `acb7924d6512cdf87a6a51d22bf75cb285f76dc9` -- official release ZIP SHA-256: `30ec3a2e149644b3fa6c51f2d6f66c180e024388b317ff6ed35000bb88ab6c7c` +- upstream tag commit: `c1a3658e12a0bb7de6fdcfb40f0d82df9f68a653` +- official release ZIP SHA-256: `6aa51a9c4d195e9537b42ec4f4ecbbf637e254442aa2641d415246f3e8dc7cf9` - Moonlit patch: [`0001-moonlit-integration.patch`](0001-moonlit-integration.patch) -- patch SHA-256: `afeb152265e5431233930237e5d4edc9c2042d9dfec00b4e0f7c09d251a649da` +- patch SHA-256: `84839a7e1141dbf2b163c2b197c5e9c2f868ffacdecef4347c59c8666874c667` + +To check a zip before vendoring it, compare its SHA-256 with the release asset +digest and verify the build attestation: + +```bash +gh release view godot-iap-3.6.1 --repo hyodotdev/openiap --json assets +gh attestation verify godot-iap-3.6.1.zip --repo hyodotdev/openiap +``` ## File provenance -| File | Official 3.5.1 SHA-256 | Moonlit SHA-256 | +| File | Official 3.6.1 SHA-256 | Moonlit SHA-256 | | --- | --- | --- | -| `godot_iap.gd` | `d872fd1d34c9075b761ca6ba2ca90c8d513b8e7d8e94f1e9a96f3b9cf9e7c2cb` | `1bf74d123066a932b1cb7baf8fb3eb5c95c6d1166ff8bc6dfa923b0058bb2238` | -| `godot_iap_plugin.gd` | `e0eb26b046700c2de24c7800927e0444ecec46e9eeb474c0d3476d865cb0fba0` | `a3eaf879fb9f7b017133e2bffa52dc3e39fae5b44fb7332dbf047986a1c4a50b` | -| `scripts/fix_ios_embed.sh` | `2b6494b705ce1beb7e23e0e5cfc8d60eff852ea3b6ffb1c72579def15f041807` | `06e1f319396196146fcfa7db967e5a82cca4d2011ba5b2ccb6fd51daf2fca249` | - -`types.gd` is **not** in that reverse-patch set. Official 3.5.1 annotates -three purchase fields as `var store: IapStore`. Moonlit's autoload is also -named `IapStore` (`res://scripts/iap/iap_store.gd`), so those annotations -do not parse. The vendored file uses `var store := IapStore.UNKNOWN` so -GDScript infers the local enum. That is a compile workaround only; do not -treat it as an upstream behavior change. - -The patch keeps project-specific boundaries: - -- Direct-distribution builds do not probe for a missing native store singleton - (`OS.has_feature("direct_distribution")` guard). -- Explicit iOS restore uses the verified 10-minute timeout instead of the - upstream 120-second default. `AppStore.sync()` can present Face ID, password, - or account sheets; two pauses there blow past 120 seconds and leave the app - UI failed while native auth continues. -- The iOS descriptor is stored as `.gdextension.ios` and only written into the - PCK under the live `.gdextension` name during export. -- `fix_ios_embed.sh` prefers `/usr/bin/python3` on macOS and fails closed when - GodotIap frameworks are missing from the Xcode project. +| `godot_iap.gd` | `2bf54bbf119886a607ea1deb2897e9d28de27ca01c7b4f7ca6daebe3a1188bfe` | `e31fdf59e230b7f677c2513a8f6323240a794382ddedf92cde771a93cf3c899d` | +| `godot_iap_plugin.gd` | `31500d82ee2ed4b78e42fcbc1dd8c28ad5d97419df18b29a72b08bd83bea9ab8` | `47ec04b62c99d99addd6e76636143007edd944cc3a6229dd416c92993b9899a6` | + +Every other file is the official file, unmodified: + +- `types.gd`: `d14c4b108c8af4f5b0b42203706fe83ada10d173e6c862c18e77bb2d69fc7970` +- `android_store.gd`: `1b9d4b80e70c2a93811c0fe973aab21b4f2149f0d391f2ca6971ecb603f43ffd` +- `scripts/fix_ios_embed.sh`: `1606332b07dcf2aa2b77ae266e476414a62423ce8769368122a52672002d938e` +- `android/GodotIap.gdap`: `b776033d531bb439ca85c69d079f4ca0f82ad2cacd35065318248349492ac806` + +`pnpm test:godot-iap-vendor` checks these hashes and that the patch reverses +to the official bytes. `types.gd` declares an `IapStore` enum, and an autoload +with that name stops its `var store: IapStore` fields from parsing, so the +game's shop autoload is `Shop`. + +## Patches kept + +- **Restore timeout** (`godot_iap.gd`): an explicit iOS restore waits up to + 600 seconds instead of 120. `AppStore.sync()` can show Face ID, password, or + account sheets, and two pauses there outlast 120 seconds, which left the app + showing a failed restore while the system sheet was still open. Upstream + 3.6.1 still uses 120 seconds. +- **iOS descriptor export** (`godot_iap_plugin.gd`): the iOS-only descriptor is + stored as `vendor/godot-iap-ios/bin/godot_iap.gdextension.ios`, and the + export plugin writes it into the PCK under the live `.gdextension` name. + Godot 4.3 to 4.7 cannot skip an iOS-only descriptor on desktop + ([godotengine/godot#105615](https://github.com/godotengine/godot/issues/105615)), + so a live copy under `res://` makes every editor and headless run log + `No GDExtension library found for current OS and architecture`, and + `pnpm game:check` must stay error-free. + +## Patches dropped + +- `fix_ios_embed.sh` preferring `/usr/bin/python3` and failing when framework + references are missing: both are upstream since 3.5.2. +- The direct-distribution guard in `_ready()`: the itch.io APK ships without + the Android plugin and `Shop.storefront_enabled()` keeps the store off there, + so without the guard the wrapper only prints that no native plugin was found. + +## Game-side changes for 3.6 + +- Release exports leave `res://iapkit.cfg` out of the bundle; godot-iap + reserves that file for local settings. Store exports write the IAPKit + publishable key to `res://iapkit_publishable.cfg` instead. +- The `Android Play` preset pins `openiap/android_store="play"`. The default, + `auto`, links the store of the one connected device in a debug export.