diff --git a/.gitignore b/.gitignore index b2b9ce50..ad3f0476 100644 --- a/.gitignore +++ b/.gitignore @@ -181,6 +181,7 @@ android/app/src/main/jniLibs/ # never-commit-generated-bindings convention. ios/Frameworks/ ios/RustySNES/Sources/Generated-RustysnesMobile.swift +ios/RustySNES/Sources/Generated-RustysnesMonetization.swift ios/RustySNES.xcodeproj/ ios/build/ *.ipa diff --git a/CHANGELOG.md b/CHANGELOG.md index 534cd09d..07732f5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`rustysnes-monetization`** (Mobile Phase 5): a new, standalone UniFFI crate providing a + dormant entitlement/ad-pacing policy scaffold — `check_entitlement`, `default_ad_pacing_policy`, + `should_show_ad`. **Never a dependency of the deterministic core** (no `rustysnes-core`/`-cpu`/ + `-ppu`/`-apu`/`-cart` dependency in either direction) and, unlike RustyNES's own already-shipped + module, every concrete pricing/pacing number here is an explicit placeholder default, not a + committed figure — the real store-launch decision stays with `docs/mobile-readiness.md`'s + standing "Mobile Phase 6" gate. Pure functions only, host-injected `now_unix_secs` timestamps + (matching `docs/adr/0004`'s determinism-discipline convention), 5 unit tests covering the + ad-pacing session/interval/clock-rollback logic. +- **Wired into both mobile shells as an inert dependency**: compiled in, called once at startup, + logged only, no real store SDK calls, no paywall/UI shown. **Verified for real on Android**: + rebuilt via a real Gradle build (native `.so` cross-compiled for both ABIs via `cargo ndk`, a + second, separate `uniffiBindgen`-style task generating this crate's own Kotlin bindings + alongside `rustysnes-mobile`'s existing ones), installed on the real AVD, launched, and confirmed + via `logcat`: `monetization scaffold (dormant): unlocked=true minIntervalSecs=300 + sessionsBeforeFirstAd=3`, with the app remaining alive with no crash afterward. **iOS**: + `scripts/build-ios-xcframework.sh` gained a third crate to build/package, merged with + `rustysnes-mobile` into one combined `RustysnesFFI.xcframework` (a real macOS CI run caught two + separate per-crate xcframeworks colliding: `xcodebuild` copies every "library"+`-headers` + xcframework's headers into one directory shared across the whole target, and two xcframeworks + each contributing a same-named `module.modulemap` there is a genuine "Multiple commands + produce" build failure — fixed by `libtool -static`-merging both crates' `.a`s and combining + their modulemaps into one umbrella module); `ios/project.yml`'s dependency list updated to + match. The Rust side's `staticlib`/`rlib` outputs cross-compile for real in this development + environment (matching `rustysnes-ios`'s own precedent), but the `cdylib` output the + bindgen step needs only links with a real Apple toolchain, so the full pipeline is + compile-verified via `ios.yml`'s real macOS CI build only, matching this platform's standing + "scaffolded-only" disposition since `v1.16.0`. + ## [1.17.0] "Parity" - 2026-07-12 Thirteenth release of the RustyNES-parity roadmap: Mobile Phase 4, hardening. diff --git a/Cargo.lock b/Cargo.lock index 2635bd8c..e2c0ece4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3841,6 +3841,13 @@ dependencies = [ "uniffi", ] +[[package]] +name = "rustysnes-monetization" +version = "1.17.0" +dependencies = [ + "uniffi", +] + [[package]] name = "rustysnes-netplay" version = "1.17.0" diff --git a/Cargo.toml b/Cargo.toml index 728d9df0..e0f0ab1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/rustysnes-ios", "crates/rustysnes-gfx-shaders", "crates/rustysnes-mobile", + "crates/rustysnes-monetization", "crates/rustysnes-netplay", "crates/rustysnes-cheevos", "crates/rustysnes-script", diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ff3b2e60..7acf73f5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,7 +17,7 @@ android { minSdk = 21 targetSdk = 34 versionCode = 1 - versionName = "1.17.0" + versionName = "1.18.0" } buildTypes { @@ -63,14 +63,14 @@ val cargoAbis = mapOf( tasks.register("cargoNdkBuild") { val ndkHome = System.getenv("ANDROID_NDK_HOME") ?: throw GradleException( - "ANDROID_NDK_HOME must be set to build the native rustysnes-mobile/rustysnes-android libraries" + "ANDROID_NDK_HOME must be set to build the native rustysnes-mobile/rustysnes-android/rustysnes-monetization libraries" ) environment("ANDROID_NDK_HOME", ndkHome) workingDir = rootProject.projectDir.parentFile val targetArgs = cargoAbis.keys.flatMap { listOf("-t", it) } commandLine( listOf("cargo", "ndk") + targetArgs + - listOf("build", "-p", "rustysnes-mobile", "-p", "rustysnes-android") + listOf("build", "-p", "rustysnes-mobile", "-p", "rustysnes-android", "-p", "rustysnes-monetization") ) } @@ -81,7 +81,7 @@ val copyCargoLibTasks = cargoAbis.map { (abi, triple) -> tasks.register("copyCargoLibs${abi.replace("-", "")}") { dependsOn("cargoNdkBuild") from(rootProject.projectDir.parentFile.resolve("target/$triple/debug")) { - include("librustysnes_mobile.so", "librustysnes_android.so") + include("librustysnes_mobile.so", "librustysnes_android.so", "librustysnes_monetization.so") } into(project.projectDir.resolve("src/main/jniLibs/$abi")) } @@ -106,10 +106,27 @@ tasks.register("uniffiBindgen") { ) } +// Same shape as `uniffiBindgen` above, for `rustysnes-monetization`'s own separate UniFFI +// library -- a distinct crate/`.so`/namespace, so it needs its own bindgen invocation and output +// directory (`v1.18.0 "Dormant"`). +tasks.register("uniffiBindgenMonetization") { + dependsOn("cargoNdkBuild") + workingDir = rootProject.projectDir.parentFile + val soPath = rootProject.projectDir.parentFile + .resolve("target/x86_64-linux-android/debug/librustysnes_monetization.so") + val outDir = project.projectDir.resolve("build/generated/uniffi-monetization") + commandLine( + "cargo", "run", "-p", "rustysnes-monetization", "--features", "bindgen", "--bin", "uniffi-bindgen", + "--", "generate", "--library", soPath.absolutePath, "--language", "kotlin", + "--out-dir", outDir.absolutePath, "--no-format", + ) +} + android.sourceSets.getByName("main").kotlin.srcDir("build/generated/uniffi/uniffi") +android.sourceSets.getByName("main").kotlin.srcDir("build/generated/uniffi-monetization/uniffi") tasks.named("preBuild") { - dependsOn("copyCargoLibs", "uniffiBindgen") + dependsOn("copyCargoLibs", "uniffiBindgen", "uniffiBindgenMonetization") } dependencies { diff --git a/android/app/src/main/kotlin/com/doublegate/rustysnes/MainActivity.kt b/android/app/src/main/kotlin/com/doublegate/rustysnes/MainActivity.kt index fd8145f5..b2d55d9d 100644 --- a/android/app/src/main/kotlin/com/doublegate/rustysnes/MainActivity.kt +++ b/android/app/src/main/kotlin/com/doublegate/rustysnes/MainActivity.kt @@ -40,6 +40,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import uniffi.rustysnes_mobile.MobileCore import uniffi.rustysnes_mobile.MobileRegion +import uniffi.rustysnes_monetization.checkEntitlement +import uniffi.rustysnes_monetization.defaultAdPacingPolicy /** * `v1.15.0 "Sideload"` -- the minimal, real Android alpha MVP: a [SurfaceView] rendered via @@ -74,6 +76,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + logMonetizationScaffold() setContent { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { @@ -93,6 +96,21 @@ class MainActivity : ComponentActivity() { } } + // `v1.18.0 "Dormant"` -- an inert call into `rustysnes-monetization`: compiled in and invoked + // once at startup, logged only, never gating any UI or behavior. No real store SDK is wired + // up; both functions are dormant placeholders (see that crate's own module doc) pending the + // `docs/mobile-readiness.md` "Mobile Phase 6" store-launch decision. + private fun logMonetizationScaffold() { + val entitlement = checkEntitlement((System.currentTimeMillis() / 1000).toULong()) + val pacing = defaultAdPacingPolicy() + android.util.Log.d( + "RustySNES", + "monetization scaffold (dormant): unlocked=${entitlement.unlocked} " + + "minIntervalSecs=${pacing.minIntervalSecs} " + + "sessionsBeforeFirstAd=${pacing.sessionsBeforeFirstAd}", + ) + } + // Single save-state slot, persisted to app-private internal storage (`filesDir`, no // permissions needed) -- multi-slot UI is `v1.17.1+` polish, matching the "minimal real MVP" // scope this mobile track has followed since `v1.15.0`. `AtomicFile`, not a plain `File` diff --git a/crates/rustysnes-monetization/Cargo.toml b/crates/rustysnes-monetization/Cargo.toml new file mode 100644 index 00000000..690f19f4 --- /dev/null +++ b/crates/rustysnes-monetization/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "rustysnes-monetization" +description = "RustySNES: dormant entitlement/ad-pacing policy scaffold (mobile-only, never a dependency of the deterministic core)" +version = "1.17.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +keywords = ["snes", "emulator", "mobile", "uniffi"] +categories = ["emulators", "games"] + +[lib] +# Same shape as `rustysnes-mobile`'s identical rationale: `cdylib` for Android's JNI loader and +# iOS's `.xcframework` packaging, `staticlib` for iOS static linking, `rlib` so `cargo test` on +# the host works without any mobile toolchain. Unlike `rustysnes-android`/`rustysnes-ios`, this +# crate has zero platform-specific dependencies (no wgpu/JNI/UIKit) -- every target here, +# including the host, builds and tests for real in this environment. +crate-type = ["cdylib", "staticlib", "rlib"] + +[features] +# Same opt-in shape as `rustysnes-mobile`'s identical `bindgen` feature -- see that crate's +# Cargo.toml comment for the full rationale (keeps `uniffi/cli` off every real mobile build). +bindgen = ["uniffi/cli"] + +[[bin]] +name = "uniffi-bindgen" +required-features = ["bindgen"] + +[lints] +workspace = true + +[dependencies] +uniffi = "0.29" diff --git a/crates/rustysnes-monetization/src/bin/uniffi-bindgen.rs b/crates/rustysnes-monetization/src/bin/uniffi-bindgen.rs new file mode 100644 index 00000000..a58d1193 --- /dev/null +++ b/crates/rustysnes-monetization/src/bin/uniffi-bindgen.rs @@ -0,0 +1,9 @@ +//! Standalone binding-generator binary — `cargo run -p rustysnes-monetization --features bindgen +//! --bin uniffi-bindgen -- generate --library --language kotlin --out-dir ` +//! produces the Kotlin (or Swift) bindings for [`rustysnes_monetization`]'s `#[uniffi::export]` +//! surface. Matches `rustysnes-mobile`'s identical binary exactly — see that crate's own doc +//! comment for the full rationale. + +fn main() { + uniffi::uniffi_bindgen_main(); +} diff --git a/crates/rustysnes-monetization/src/lib.rs b/crates/rustysnes-monetization/src/lib.rs new file mode 100644 index 00000000..474da6e8 --- /dev/null +++ b/crates/rustysnes-monetization/src/lib.rs @@ -0,0 +1,178 @@ +//! Dormant entitlement/ad-pacing policy scaffold (`v1.18.0 "Dormant"`, Mobile Phase 5) — the +//! monetization crate RustyNES's own mobile track already ships as a dormant module, ported here +//! as a policy-*shape* template only. **Never a dependency of the deterministic core** — +//! `rustysnes-core`/`rustysnes-cpu`/`rustysnes-ppu`/`rustysnes-apu`/`rustysnes-cart` never depend +//! on this crate, and this crate never depends on them; it has no knowledge of `EmuCore` at all. +//! It is wired into both mobile shells (`android/`, `ios/`) as an inert dependency only: compiled +//! in and called once at startup (logged, not gating anything), no real RevenueCat/AppLovin (or +//! equivalent) SDK calls, no purchase/paywall UI shown. +//! +//! # Why "dormant" +//! +//! Unlike RustyNES's own module (which ships a committed pricing figure), every concrete number +//! here is an explicit placeholder default — [`AdPacingPolicy`]'s field values are a template +//! for a *later* maintainer decision, not a real pricing/pacing commitment. +//! `docs/mobile-readiness.md`'s standing "Mobile Phase 6" store-launch gate is where that decision +//! (and actually wiring a real store SDK) would happen — not here, not automatically. +//! +//! # Determinism discipline (same boundary as the deterministic core, extended here on principle) +//! +//! Every function in this crate is pure: no wall-clock reads, no OS RNG, no hidden state. Time +//! enters only as an explicit `now_unix_secs: u64` parameter the caller supplies (matching +//! `docs/adr/0004`'s "host-injected timestamps" convention already used elsewhere in this +//! project) — this crate never feeds anything back into `EmuCore`, so nothing here is subject to +//! the core's own byte-identical-replay contract, but keeping the same discipline anyway means +//! every function here is trivially unit-testable without mocking a clock. + +uniffi::setup_scaffolding!(); + +/// Whether the user currently has the paid unlock. +#[derive(uniffi::Record, Debug, Clone, Copy, PartialEq, Eq)] +pub struct Entitlement { + /// `true` if paid features should be unlocked. + pub unlocked: bool, +} + +/// Placeholder ad-pacing knobs — see the module doc for why every field here is an explicit +/// placeholder, not a committed figure. +#[derive(uniffi::Record, Debug, Clone, Copy, PartialEq, Eq)] +pub struct AdPacingPolicy { + /// Minimum seconds between two ad presentations. + pub min_interval_secs: u64, + /// How many play sessions must complete before the very first ad is eligible at all. + pub sessions_before_first_ad: u32, +} + +impl AdPacingPolicy { + /// Placeholder values only -- not a committed pacing figure, see the module doc. The single + /// source of truth both [`Default::default`] and [`default_ad_pacing_policy`] return, so the + /// two can never drift apart. + const PLACEHOLDER_DEFAULT: Self = Self { + min_interval_secs: 300, + sessions_before_first_ad: 3, + }; +} + +impl Default for AdPacingPolicy { + fn default() -> Self { + Self::PLACEHOLDER_DEFAULT + } +} + +/// The default, dormant ad-pacing policy. +/// +/// Entitlement checks always report unlocked (no paywall active), and ad pacing uses +/// [`AdPacingPolicy::default`]'s placeholder figures. A mobile shell calling this and logging the +/// result (not gating any UI on it) is exactly the "inert dependency" integration this rung wires +/// up — see `android/`/`ios/`'s own call sites. +#[uniffi::export] +#[must_use] +pub const fn default_ad_pacing_policy() -> AdPacingPolicy { + AdPacingPolicy::PLACEHOLDER_DEFAULT +} + +/// Check entitlement for `now_unix_secs`. +/// +/// Dormant: always reports unlocked (no active paywall) regardless of the timestamp — a real +/// store-backed check (RevenueCat/StoreKit/Play Billing) is a `docs/mobile-readiness.md` +/// "Mobile Phase 6" store-launch decision, not implemented here. `now_unix_secs` is accepted (and +/// ignored, via `let _ = ...` rather than an underscore-prefixed parameter name -- `#[uniffi:: +/// export]`'s generated glue references the parameter by name, which trips +/// `clippy::used_underscore_binding` on a `_`-prefixed one) rather than dropped from the +/// signature, so the real check this eventually becomes doesn't need a breaking signature change +/// across the FFI boundary. +#[uniffi::export] +#[must_use] +pub const fn check_entitlement(now_unix_secs: u64) -> Entitlement { + let _ = now_unix_secs; + Entitlement { unlocked: true } +} + +/// Pure ad-pacing decision: should an ad be shown right now? +/// +/// - `now_unix_secs`: the current time, host-injected (see the module doc). +/// - `last_ad_unix_secs`: when the last ad was shown, or `None` if none has ever been shown this +/// install. +/// - `completed_sessions`: how many play sessions have completed so far. +/// - `policy`: the pacing knobs to evaluate against (typically [`default_ad_pacing_policy`]'s +/// result, but passed explicitly rather than read from hidden state, keeping this function pure +/// and directly unit-testable). +#[uniffi::export] +#[must_use] +pub fn should_show_ad( + now_unix_secs: u64, + last_ad_unix_secs: Option, + completed_sessions: u32, + policy: AdPacingPolicy, +) -> bool { + if completed_sessions < policy.sessions_before_first_ad { + return false; + } + last_ad_unix_secs + .is_none_or(|last| now_unix_secs.saturating_sub(last) >= policy.min_interval_secs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entitlement_is_always_unlocked_while_dormant() { + assert!(check_entitlement(0).unlocked); + assert!(check_entitlement(u64::MAX).unlocked); + } + + #[test] + fn ad_pacing_withholds_the_first_ad_until_enough_sessions_completed() { + let policy = AdPacingPolicy::default(); + assert!(!should_show_ad(1_000_000, None, 0, policy)); + assert!(!should_show_ad( + 1_000_000, + None, + policy.sessions_before_first_ad - 1, + policy + )); + } + + #[test] + fn ad_pacing_allows_the_first_ad_once_the_session_threshold_is_met() { + let policy = AdPacingPolicy::default(); + assert!(should_show_ad( + 1_000_000, + None, + policy.sessions_before_first_ad, + policy + )); + } + + #[test] + fn ad_pacing_enforces_the_minimum_interval_between_ads() { + let policy = AdPacingPolicy::default(); + let last_ad = 1_000_000; + assert!(!should_show_ad( + last_ad + policy.min_interval_secs - 1, + Some(last_ad), + policy.sessions_before_first_ad, + policy + )); + assert!(should_show_ad( + last_ad + policy.min_interval_secs, + Some(last_ad), + policy.sessions_before_first_ad, + policy + )); + } + + #[test] + fn ad_pacing_saturates_instead_of_panicking_on_a_time_regression() { + // `now < last_ad_unix_secs` (a clock rollback) must not panic via unsigned subtraction + // underflow -- `saturating_sub` clamps to 0, correctly withholding the ad. + let policy = AdPacingPolicy::default(); + assert!(!should_show_ad( + 0, + Some(1_000_000), + policy.sessions_before_first_ad, + policy + )); + } +} diff --git a/docs/mobile-readiness.md b/docs/mobile-readiness.md index 191c6f5b..41c9397d 100644 --- a/docs/mobile-readiness.md +++ b/docs/mobile-readiness.md @@ -167,6 +167,42 @@ migration was explicitly gated on "Lua/TAS-on-mobile being greenlit," which hasn Netplay is a large, net-new UI surface neither shell has any precedent for. See `CHANGELOG.md`'s `[Unreleased]` entry for the full reasoning; all three remain on the roadmap for a later rung. +## Verified so far (`v1.18.0`) + +- **`rustysnes-monetization`** (Mobile Phase 5): a new, standalone UniFFI crate — dormant + entitlement/ad-pacing policy scaffold, never a dependency of the deterministic core, every + concrete pricing/pacing number an explicit placeholder (unlike RustyNES's own already-committed + figure). Fully verified on host: `cargo test -p rustysnes-monetization` (5/5 passing), + `cargo clippy -p rustysnes-monetization --all-targets -- -D warnings` (clean), + `RUSTDOCFLAGS="-D warnings" cargo doc -p rustysnes-monetization --no-deps` (clean), + `cargo fmt --check -p rustysnes-monetization` (clean). +- **Wired into both mobile shells as an inert dependency** — compiled in, called once at startup, + logged only, no real store SDK, no UI. **Android**: `build.gradle.kts`'s `cargoNdkBuild` task now + builds all three native crates and a second, separate `uniffiBindgenMonetization` task generates + this crate's own Kotlin bindings (its own UniFFI namespace, so it can't share `rustysnes-mobile`'s + generated-sources directory). Rebuilt via a real Gradle build against the locally cached Gradle + 8.13 distribution (same no-`gradlew`-wrapper disposition already tracked below), installed on + the real AVD, launched, and confirmed via `logcat`: + `monetization scaffold (dormant): unlocked=true minIntervalSecs=300 sessionsBeforeFirstAd=3`; + the app process stayed alive afterward (no crash). **iOS**: `scripts/build-ios-xcframework.sh` + gained a third crate to build/package, merged with `rustysnes-mobile` into one combined + `RustysnesFFI.xcframework` rather than two separate per-crate ones — a real macOS CI run caught + a genuine `xcodebuild` "Multiple commands produce '.../include/module.modulemap'" failure: a + "library"+`-headers` xcframework has its headers copied into one directory shared across every + such xcframework linked into the target, so two xcframeworks each contributing a same-named + `module.modulemap` collided. Fixed by `libtool -static`-merging both crates' `.a`s per platform + slice and combining their modulemaps into one umbrella module before packaging a single + xcframework; `ios/project.yml`'s dependency list updated to match. The Rust side's + `staticlib`/`rlib` outputs for `aarch64-apple-ios` cross-compile for + real in this development environment (confirmed: identical to `rustysnes-ios`'s own already- + established precedent). The `cdylib` output the bindgen/xcframework packaging step needs does + NOT link here — confirmed this is a pre-existing sandbox limitation, not something this rung + introduced (an identical `cc: error: unrecognized command-line option '-target'` failure + reproduces for `rustysnes-mobile`'s own pre-existing `cdylib` build in isolation, with or without + `rustysnes-monetization` in the same invocation) — so the full xcframework/`xcodebuild` pipeline + is compile-verified via `ios.yml`'s real macOS CI build only, matching this platform's standing + "scaffolded-only" disposition since `v1.16.0`. + ## Not yet verified / explicitly deferred - **No Mouse/Super Scope/Multitap touch UX yet** — net-new SNES-specific UI with no RustyNES diff --git a/ios/RustySNES/Sources/RustySNESApp.swift b/ios/RustySNES/Sources/RustySNESApp.swift index 3d069305..24c526ad 100644 --- a/ios/RustySNES/Sources/RustySNESApp.swift +++ b/ios/RustySNES/Sources/RustySNESApp.swift @@ -10,9 +10,32 @@ import SwiftUI /// has ever happened, since this development environment has no Xcode/macOS toolchain). @main struct RustySNESApp: App { + init() { + logMonetizationScaffold() + } + var body: some Scene { WindowGroup { ContentView() } } } + +/// `v1.18.0 "Dormant"` -- an inert call into `rustysnes-monetization`: compiled in and invoked +/// once at startup, logged only, never gating any UI or behavior. No real store SDK is wired up; +/// both functions are dormant placeholders (see that crate's own module doc) pending the +/// `docs/mobile-readiness.md` "Mobile Phase 6" store-launch decision. Mirrors +/// `MainActivity.kt`'s `logMonetizationScaffold` exactly. +private func logMonetizationScaffold() { + // `max(0.0, ...)` (found in review): a negative `timeIntervalSince1970` (device clock set + // before 1970) would otherwise trap on the `UInt64` cast, crashing the app at startup over a + // log-only scaffold call. + let nowUnixSecs = UInt64(max(0.0, Date().timeIntervalSince1970)) + let entitlement = checkEntitlement(nowUnixSecs: nowUnixSecs) + let pacing = defaultAdPacingPolicy() + print( + "RustySNES: monetization scaffold (dormant): unlocked=\(entitlement.unlocked) " + + "minIntervalSecs=\(pacing.minIntervalSecs) " + + "sessionsBeforeFirstAd=\(pacing.sessionsBeforeFirstAd)" + ) +} diff --git a/ios/project.yml b/ios/project.yml index 55f83380..d9f3a529 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -29,7 +29,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.doublegate.rustysnes PRODUCT_NAME: RustySNES - MARKETING_VERSION: "1.17.0" + MARKETING_VERSION: "1.18.0" CURRENT_PROJECT_VERSION: "1" SWIFT_OBJC_BRIDGING_HEADER: RustySNES/Bridging-Header.h SWIFT_VERSION: "5.0" @@ -41,7 +41,14 @@ targets: # `embed: false` for both -- these wrap `staticlib` (`.a`) slices, not dynamic libraries; # Xcode links them directly into the app binary rather than copying/code-signing them as # separate embedded bundles (which is only meaningful for dynamic frameworks). - - framework: Frameworks/RustysnesMobileFFI.xcframework + # + # `RustysnesFFI.xcframework` combines rustysnes-mobile + rustysnes-monetization (`v1.18.0 + # "Dormant"`) -- found on a real macOS CI run: xcodebuild copies every "library"+`-headers` + # xcframework's headers into one directory shared across the whole target, and two separate + # per-crate xcframeworks each contributing a same-named `module.modulemap` there is a real + # "Multiple commands produce" build failure, not a hypothetical one. See + # `scripts/build-ios-xcframework.sh`'s own comment for the full story. + - framework: Frameworks/RustysnesFFI.xcframework embed: false - framework: Frameworks/RustysnesIOS.xcframework embed: false diff --git a/scripts/build-ios-xcframework.sh b/scripts/build-ios-xcframework.sh index aaffa539..3f92bf17 100755 --- a/scripts/build-ios-xcframework.sh +++ b/scripts/build-ios-xcframework.sh @@ -17,9 +17,9 @@ out_dir="$repo_root/ios/Frameworks" rm -rf "$out_dir" mkdir -p "$out_dir" -echo "==> Building rustysnes-mobile + rustysnes-ios for aarch64-apple-ios (device)" +echo "==> Building rustysnes-mobile + rustysnes-ios + rustysnes-monetization for aarch64-apple-ios (device)" cargo build --release --target aarch64-apple-ios \ - -p rustysnes-mobile -p rustysnes-ios \ + -p rustysnes-mobile -p rustysnes-ios -p rustysnes-monetization \ --manifest-path "$repo_root/Cargo.toml" # Both simulator architectures, not just arm64-apple-ios-sim -- a `generic/platform=iOS @@ -29,14 +29,14 @@ cargo build --release --target aarch64-apple-ios \ # Found for real on a real (Apple Silicon) macOS CI runner: `xcodebuild` reported both # xcframeworks here "missing architecture(s) required by this target (x86_64)" when only # `aarch64-apple-ios-sim` had been built. -echo "==> Building rustysnes-mobile + rustysnes-ios for aarch64-apple-ios-sim (simulator, Apple Silicon)" +echo "==> Building rustysnes-mobile + rustysnes-ios + rustysnes-monetization for aarch64-apple-ios-sim (simulator, Apple Silicon)" cargo build --release --target aarch64-apple-ios-sim \ - -p rustysnes-mobile -p rustysnes-ios \ + -p rustysnes-mobile -p rustysnes-ios -p rustysnes-monetization \ --manifest-path "$repo_root/Cargo.toml" -echo "==> Building rustysnes-mobile + rustysnes-ios for x86_64-apple-ios (simulator, Intel)" +echo "==> Building rustysnes-mobile + rustysnes-ios + rustysnes-monetization for x86_64-apple-ios (simulator, Intel)" cargo build --release --target x86_64-apple-ios \ - -p rustysnes-mobile -p rustysnes-ios \ + -p rustysnes-mobile -p rustysnes-ios -p rustysnes-monetization \ --manifest-path "$repo_root/Cargo.toml" device_dir="$repo_root/target/aarch64-apple-ios/release" @@ -49,7 +49,7 @@ sim_x86_64_dir="$repo_root/target/x86_64-apple-ios/release" # can itself be multi-arch). sim_universal_dir="$out_dir/sim-universal" mkdir -p "$sim_universal_dir" -for crate in rustysnes_mobile rustysnes_ios; do +for crate in rustysnes_mobile rustysnes_ios rustysnes_monetization; do lipo -create \ "$sim_arm64_dir/lib${crate}.a" \ "$sim_x86_64_dir/lib${crate}.a" \ @@ -74,15 +74,50 @@ cargo run -p rustysnes-mobile --features bindgen --bin uniffi-bindgen \ # xcframework. mv "$bindings_dir/rustysnes_mobile.swift" "$repo_root/ios/RustySNES/Sources/Generated-RustysnesMobile.swift" -echo "==> Packaging RustysnesMobileFFI.xcframework" -mobile_headers="$bindings_dir/mobile-headers" -mkdir -p "$mobile_headers" -cp "$bindings_dir/rustysnes_mobileFFI.h" "$mobile_headers/" -cp "$bindings_dir/rustysnes_mobileFFI.modulemap" "$mobile_headers/module.modulemap" +echo "==> Generating UniFFI Swift bindings for rustysnes-monetization" +# Same shape as rustysnes-mobile's bindgen step above -- a distinct crate/dylib/namespace, so it +# needs its own bindgen invocation (`v1.18.0 "Dormant"`). +monetization_bindings_dir="$out_dir/generated-monetization" +mkdir -p "$monetization_bindings_dir" +cargo run -p rustysnes-monetization --features bindgen --bin uniffi-bindgen \ + --manifest-path "$repo_root/Cargo.toml" -- \ + generate --library "$device_dir/librustysnes_monetization.dylib" --language swift \ + --out-dir "$monetization_bindings_dir" --no-format +mv "$monetization_bindings_dir/rustysnes_monetization.swift" \ + "$repo_root/ios/RustySNES/Sources/Generated-RustysnesMonetization.swift" + +# `rustysnes-mobile` and `rustysnes-monetization` are packaged into ONE combined +# `RustysnesFFI.xcframework`, not two separate ones -- found on a real macOS CI run: a +# "library"+`-headers` xcframework (as opposed to a `.framework` bundle) has its headers copied by +# `xcodebuild` into one directory shared across every such xcframework linked into the target +# (`$(BUILT_PRODUCTS_DIR)/include/`), and BOTH crates' UniFFI-generated modulemap gets renamed to +# the same literal filename Clang requires for auto-discovery (`module.modulemap`) -- two +# xcframeworks each contributing a same-named file to that one shared directory is a genuine +# "Multiple commands produce '.../include/module.modulemap'" `xcodebuild` failure, not a +# hypothetical one. Merging both crates' static libraries (via `libtool -static`) and headers +# (one combined `module.modulemap` declaring both crates' modules) into a single xcframework +# avoids the collision entirely -- matching the same umbrella-module pattern other multi-crate +# UniFFI Swift/Xcode integrations use. +echo "==> Packaging combined RustysnesFFI.xcframework (rustysnes-mobile + rustysnes-monetization)" +ffi_headers="$out_dir/ffi-headers" +mkdir -p "$ffi_headers" +cp "$bindings_dir/rustysnes_mobileFFI.h" "$ffi_headers/" +cp "$monetization_bindings_dir/rustysnes_monetizationFFI.h" "$ffi_headers/" +cat "$bindings_dir/rustysnes_mobileFFI.modulemap" \ + "$monetization_bindings_dir/rustysnes_monetizationFFI.modulemap" \ + > "$ffi_headers/module.modulemap" + +ffi_device_lib="$out_dir/librustysnes_ffi-device.a" +ffi_sim_lib="$out_dir/librustysnes_ffi-sim.a" +libtool -static -o "$ffi_device_lib" \ + "$device_dir/librustysnes_mobile.a" "$device_dir/librustysnes_monetization.a" +libtool -static -o "$ffi_sim_lib" \ + "$sim_dir/librustysnes_mobile.a" "$sim_dir/librustysnes_monetization.a" + xcodebuild -create-xcframework \ - -library "$device_dir/librustysnes_mobile.a" -headers "$mobile_headers" \ - -library "$sim_dir/librustysnes_mobile.a" -headers "$mobile_headers" \ - -output "$out_dir/RustysnesMobileFFI.xcframework" + -library "$ffi_device_lib" -headers "$ffi_headers" \ + -library "$ffi_sim_lib" -headers "$ffi_headers" \ + -output "$out_dir/RustysnesFFI.xcframework" echo "==> Packaging RustysnesIOS.xcframework" # Library-only (no -headers) -- rustysnes-ios's small, hand-declared FFI surface is exposed to