From d0d5b2019d41b74eb4069512a9663713f63105ee Mon Sep 17 00:00:00 2001 From: drei Date: Mon, 25 May 2026 15:16:43 -0500 Subject: [PATCH 1/9] SDK: add Flutter/Dart bindings via flutter_rust_bridge Adds a new `flutter_ffi` Rust crate that exposes the same API surface as the existing UniFFI SDK but annotated for flutter_rust_bridge v2. CI generates Dart bindings with `flutter_rust_bridge_codegen`, builds native iOS XCFramework + Android .so files, and ships a self-contained `rift-flutter-sdk-{tag}.tar.gz` release artifact alongside the existing iOS and Android tarballs. Storage is owned by the Dart layer (no foreign trait needed): `RiftSdk.create()` accepts pre-loaded state; methods that mutate state return a `RiftState` snapshot for Dart to persist. The `SharedPrefsRiftStorage` default implementation handles this transparently out of the box. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mobile-sdk-ci.yml | 33 ++ .github/workflows/mobile-sdk-release.yml | 69 ++- client/mobile/Cargo.lock | 358 +++++++++++++++ client/mobile/Cargo.toml | 1 + client/mobile/build_flutter.sh | 95 ++++ .../android/build.gradle | 54 +++ .../android/src/main/AndroidManifest.xml | 1 + .../ink/riftl/sdk/RiftFlutterFfiPlugin.kt | 10 + .../ios/Classes/RiftFlutterFfiPlugin.swift | 7 + .../ios/rift_flutter_ffi.podspec | 24 + .../flutter_dart_wrappers/lib/rift_sdk.dart | 21 + .../lib/src/rift_sdk_impl.dart | 124 +++++ .../lib/src/rift_storage.dart | 39 ++ .../mobile/flutter_dart_wrappers/pubspec.yaml | 25 + client/mobile/flutter_ffi/Cargo.toml | 21 + client/mobile/flutter_ffi/build.rs | 6 + client/mobile/flutter_ffi/src/lib.rs | 431 ++++++++++++++++++ 17 files changed, 1318 insertions(+), 1 deletion(-) create mode 100755 client/mobile/build_flutter.sh create mode 100644 client/mobile/flutter_dart_wrappers/android/build.gradle create mode 100644 client/mobile/flutter_dart_wrappers/android/src/main/AndroidManifest.xml create mode 100644 client/mobile/flutter_dart_wrappers/android/src/main/kotlin/ink/riftl/sdk/RiftFlutterFfiPlugin.kt create mode 100644 client/mobile/flutter_dart_wrappers/ios/Classes/RiftFlutterFfiPlugin.swift create mode 100644 client/mobile/flutter_dart_wrappers/ios/rift_flutter_ffi.podspec create mode 100644 client/mobile/flutter_dart_wrappers/lib/rift_sdk.dart create mode 100644 client/mobile/flutter_dart_wrappers/lib/src/rift_sdk_impl.dart create mode 100644 client/mobile/flutter_dart_wrappers/lib/src/rift_storage.dart create mode 100644 client/mobile/flutter_dart_wrappers/pubspec.yaml create mode 100644 client/mobile/flutter_ffi/Cargo.toml create mode 100644 client/mobile/flutter_ffi/build.rs create mode 100644 client/mobile/flutter_ffi/src/lib.rs diff --git a/.github/workflows/mobile-sdk-ci.yml b/.github/workflows/mobile-sdk-ci.yml index e1f5d8e2..7defff2e 100644 --- a/.github/workflows/mobile-sdk-ci.yml +++ b/.github/workflows/mobile-sdk-ci.yml @@ -38,3 +38,36 @@ jobs: - name: Test run: cargo test + + flutter-dart-gen: + name: Flutter Dart Binding Generation + runs-on: ubuntu-latest + defaults: + run: + working-directory: client/mobile + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: client/mobile + + - name: Install flutter_rust_bridge_codegen + run: cargo install flutter_rust_bridge_codegen + + - name: Generate Dart bindings + run: | + mkdir -p dist/flutter/lib/src/rust dist/flutter/headers + flutter_rust_bridge_codegen generate \ + --rust-input flutter_ffi/src/lib.rs \ + --dart-output dist/flutter/lib/src/rust/frb_generated.dart \ + --c-output dist/flutter/headers/rift_flutter_ffi.h \ + --no-web + + - name: Verify generated files + run: | + test -f dist/flutter/lib/src/rust/frb_generated.dart + echo "Dart bindings generated successfully" diff --git a/.github/workflows/mobile-sdk-release.yml b/.github/workflows/mobile-sdk-release.yml index 9551b91c..dbf2495b 100644 --- a/.github/workflows/mobile-sdk-release.yml +++ b/.github/workflows/mobile-sdk-release.yml @@ -117,9 +117,60 @@ jobs: path: client/mobile/android-sdk.tar.gz retention-days: 5 + build-flutter: + name: Build Flutter SDK + runs-on: macos-15 + timeout-minutes: 40 + defaults: + run: + working-directory: client/mobile + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Add Apple build targets + run: rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin + + - name: Add Android targets + run: rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android + + - name: Install cargo-ndk + run: cargo install cargo-ndk + + - name: Setup Android NDK + uses: android-actions/setup-android@v3 + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: client/mobile + + - name: Install flutter_rust_bridge_codegen + run: cargo install flutter_rust_bridge_codegen + + - name: Build Flutter SDK + run: ./build_flutter.sh + + - name: Verify artifacts + run: | + test -f dist/flutter/lib/src/rust/frb_generated.dart + test -d dist/flutter/ios/rift_flutter_ffi.xcframework + test -d dist/flutter/android/jniLibs/arm64-v8a + echo "Flutter artifacts verified" + + - name: Package Flutter SDK + run: tar -czf flutter-sdk.tar.gz -C dist flutter + + - uses: actions/upload-artifact@v4 + with: + name: flutter-sdk + path: client/mobile/flutter-sdk.tar.gz + retention-days: 5 + release: name: Create GitHub Release - needs: [build-ios, build-android] + needs: [build-ios, build-android, build-flutter] runs-on: ubuntu-latest steps: @@ -137,6 +188,11 @@ jobs: name: android-sdk path: artifacts/ + - uses: actions/download-artifact@v4 + with: + name: flutter-sdk + path: artifacts/ + - name: Determine version id: version run: | @@ -186,6 +242,7 @@ jobs: TAG="${{ steps.version.outputs.tag }}" mv artifacts/ios-sdk.tar.gz "artifacts/rift-ios-sdk-${TAG}.tar.gz" mv artifacts/android-sdk.tar.gz "artifacts/rift-android-sdk-${TAG}.tar.gz" + mv artifacts/flutter-sdk.tar.gz "artifacts/rift-flutter-sdk-${TAG}.tar.gz" # IMPORTANT — order matters here. Both the release tag and the SPM # semver tag must point at the commit that has the URL+checksum @@ -251,9 +308,19 @@ jobs: ### Android (Gradle) Download `rift-android-sdk-${{ steps.version.outputs.tag }}.tar.gz`, extract into your project. Contains: jniLibs (arm64-v8a, armeabi-v7a, x86, x86_64), Kotlin bindings, `build.gradle.kts` + + ### Flutter / Dart + Download `rift-flutter-sdk-${{ steps.version.outputs.tag }}.tar.gz`, extract, and add as a path dependency: + ```yaml + dependencies: + rift_flutter_ffi: + path: ./rift_flutter_ffi + ``` + Contains: generated Dart bindings, hand-written wrapper, iOS XCFramework, Android jniLibs. files: | artifacts/rift-ios-sdk-${{ steps.version.outputs.tag }}.tar.gz artifacts/rift-android-sdk-${{ steps.version.outputs.tag }}.tar.gz + artifacts/rift-flutter-sdk-${{ steps.version.outputs.tag }}.tar.gz artifacts/rift_ffiFFI.xcframework.zip generate_release_notes: true env: diff --git a/client/mobile/Cargo.lock b/client/mobile/Cargo.lock index d3d1fcb0..aec15c72 100644 --- a/client/mobile/Cargo.lock +++ b/client/mobile/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +26,34 @@ dependencies = [ "memchr", ] +[[package]] +name = "allo-isolate" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550" +dependencies = [ + "anyhow", + "atomic", + "backtrace", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + [[package]] name = "anstyle" version = "1.0.14" @@ -88,6 +131,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -100,6 +149,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.22.1" @@ -121,12 +185,39 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "build-target" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -226,6 +317,48 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dart-sys" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895" +dependencies = [ + "cc", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -244,6 +377,27 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "delegate-attr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -255,6 +409,16 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -283,6 +447,48 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flutter_rust_bridge" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0884853aae8a6517b5b58cf36f55da487f2fe110e1686938eb29b6640aae4a5" +dependencies = [ + "allo-isolate", + "android_logger", + "anyhow", + "build-target", + "bytemuck", + "byteorder", + "console_error_panic_hook", + "dart-sys", + "delegate-attr", + "flutter_rust_bridge_macros", + "futures", + "js-sys", + "lazy_static", + "log", + "oslog", + "portable-atomic", + "threadpool", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "flutter_rust_bridge_macros" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b5ce32f35f710ced8c5aa557f023f1a624e737b5460cee2b70fcd3a8df09e1b" +dependencies = [ + "hex", + "md-5", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fnv" version = "1.0.7" @@ -401,6 +607,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -441,6 +657,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "glob" version = "0.3.3" @@ -477,6 +699,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -504,6 +732,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -794,6 +1028,15 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -815,6 +1058,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" @@ -827,6 +1080,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.1.1" @@ -867,12 +1129,45 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap", + "log", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -897,6 +1192,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1039,6 +1340,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1134,6 +1444,21 @@ dependencies = [ "wiremock", ] +[[package]] +name = "rift_flutter_ffi" +version = "0.2.0" +dependencies = [ + "flutter_rust_bridge", + "reqwest", + "rift_sdk_core", + "serde_json", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "rift_mobile" version = "0.2.0" @@ -1166,6 +1491,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1232,6 +1563,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -1472,6 +1809,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1667,6 +2013,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1855,6 +2207,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" diff --git a/client/mobile/Cargo.toml b/client/mobile/Cargo.toml index 9c6d2f2b..fb748cfe 100644 --- a/client/mobile/Cargo.toml +++ b/client/mobile/Cargo.toml @@ -5,4 +5,5 @@ members = [ "ffi", "mobile", "uniffi-bindgen", + "flutter_ffi", ] diff --git a/client/mobile/build_flutter.sh b/client/mobile/build_flutter.sh new file mode 100755 index 00000000..6f36ac9f --- /dev/null +++ b/client/mobile/build_flutter.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Build the Rift Flutter SDK: +# - Generates Dart bindings via flutter_rust_bridge_codegen +# - Builds native static libs for iOS targets → XCFramework +# - Builds native shared libs for Android targets +# - Packages everything into dist/flutter/ +set -euo pipefail +cd "$(dirname "$0")" + +CRATE="rift_flutter_ffi" +DIST="dist/flutter" +HEADERS_DIR="$DIST/headers" +IOS_DIST="$DIST/ios" +ANDROID_DIST="$DIST/android" + +rm -rf "$DIST" +mkdir -p "$HEADERS_DIR" "$IOS_DIST" "$ANDROID_DIST" + +# ── Step 1: Generate Dart bindings ────────────────────────────────────────── + +if ! command -v flutter_rust_bridge_codegen &>/dev/null; then + echo "[Rift] Installing flutter_rust_bridge_codegen..." + cargo install flutter_rust_bridge_codegen +fi + +# frb codegen reads the Rust source and emits Dart + C header. +flutter_rust_bridge_codegen generate \ + --rust-input "flutter_ffi/src/lib.rs" \ + --dart-output "$DIST/lib/src/rust/frb_generated.dart" \ + --c-output "$HEADERS_DIR/${CRATE}.h" \ + --no-web + +echo "[Rift] Dart bindings generated → $DIST/lib/src/rust/" + +# ── Step 2: Build iOS targets ──────────────────────────────────────────────── + +APPLE_TARGETS=(aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin) +for target in "${APPLE_TARGETS[@]}"; do + echo "[Rift] Building $target..." + cargo build --release --target "$target" -p "$CRATE" +done + +# Bundle into XCFramework (device + simulator + macOS). +xcodebuild -create-xcframework \ + -library "target/aarch64-apple-ios/release/lib${CRATE}.a" \ + -headers "$HEADERS_DIR" \ + -library "target/aarch64-apple-ios-sim/release/lib${CRATE}.a" \ + -headers "$HEADERS_DIR" \ + -library "target/aarch64-apple-darwin/release/lib${CRATE}.a" \ + -headers "$HEADERS_DIR" \ + -output "$IOS_DIST/${CRATE}.xcframework" + +echo "[Rift] XCFramework → $IOS_DIST/${CRATE}.xcframework" + +# ── Step 3: Build Android targets ──────────────────────────────────────────── + +if ! command -v cargo-ndk &>/dev/null; then + echo "[Rift] Installing cargo-ndk..." + cargo install cargo-ndk +fi + +declare -A ABI_MAP=( + [aarch64-linux-android]="arm64-v8a" + [armv7-linux-androideabi]="armeabi-v7a" + [i686-linux-android]="x86" + [x86_64-linux-android]="x86_64" +) + +for target in "${!ABI_MAP[@]}"; do + abi="${ABI_MAP[$target]}" + echo "[Rift] Building $target ($abi)..." + cargo ndk --target "$target" --platform 21 build --release -p "$CRATE" + mkdir -p "$ANDROID_DIST/jniLibs/$abi" + cp "target/$target/release/lib${CRATE}.so" "$ANDROID_DIST/jniLibs/$abi/" +done + +echo "[Rift] Android libraries → $ANDROID_DIST/jniLibs/" + +# ── Step 4: Assemble the plugin package ────────────────────────────────────── + +# Copy hand-written Dart wrapper and plugin boilerplate. +# Preserve the frb-generated lib/src/rust/ directory. +cp -r flutter_dart_wrappers/lib/. "$DIST/lib/" +cp flutter_dart_wrappers/pubspec.yaml "$DIST/" +cp -r flutter_dart_wrappers/ios/Classes "$IOS_DIST/" +cp flutter_dart_wrappers/ios/rift_flutter_ffi.podspec "$IOS_DIST/" +# Place the XCFramework next to the podspec so CocoaPods can find it. +# (Already built above into $IOS_DIST.) + +cp -r flutter_dart_wrappers/android/. "$ANDROID_DIST/" + +echo "[Rift] Flutter SDK assembled → $DIST" +echo " To use as a path dependency in pubspec.yaml:" +echo " rift_flutter_ffi:" +echo " path: ./path/to/$DIST" diff --git a/client/mobile/flutter_dart_wrappers/android/build.gradle b/client/mobile/flutter_dart_wrappers/android/build.gradle new file mode 100644 index 00000000..52c2c8e3 --- /dev/null +++ b/client/mobile/flutter_dart_wrappers/android/build.gradle @@ -0,0 +1,54 @@ +group 'ink.riftl.sdk' +version '0.2.0' + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:7.4.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 34 + ndkVersion "25.2.9519653" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main { + manifest.srcFile 'src/main/AndroidManifest.xml' + kotlin.srcDirs = ['src/main/kotlin'] + // Prebuilt Rust shared libraries — placed by the release tarball. + jniLibs.srcDirs = ['jniLibs'] + } + } + + defaultConfig { + minSdkVersion 21 + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0" +} diff --git a/client/mobile/flutter_dart_wrappers/android/src/main/AndroidManifest.xml b/client/mobile/flutter_dart_wrappers/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..25139f53 --- /dev/null +++ b/client/mobile/flutter_dart_wrappers/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/client/mobile/flutter_dart_wrappers/android/src/main/kotlin/ink/riftl/sdk/RiftFlutterFfiPlugin.kt b/client/mobile/flutter_dart_wrappers/android/src/main/kotlin/ink/riftl/sdk/RiftFlutterFfiPlugin.kt new file mode 100644 index 00000000..2787cab2 --- /dev/null +++ b/client/mobile/flutter_dart_wrappers/android/src/main/kotlin/ink/riftl/sdk/RiftFlutterFfiPlugin.kt @@ -0,0 +1,10 @@ +package ink.riftl.sdk + +import io.flutter.embedding.engine.plugins.FlutterPlugin + +// flutter_rust_bridge handles all FFI via dart:ffi. +// This class only exists to satisfy Flutter's plugin registration mechanism. +class RiftFlutterFfiPlugin : FlutterPlugin { + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {} + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {} +} diff --git a/client/mobile/flutter_dart_wrappers/ios/Classes/RiftFlutterFfiPlugin.swift b/client/mobile/flutter_dart_wrappers/ios/Classes/RiftFlutterFfiPlugin.swift new file mode 100644 index 00000000..ae442410 --- /dev/null +++ b/client/mobile/flutter_dart_wrappers/ios/Classes/RiftFlutterFfiPlugin.swift @@ -0,0 +1,7 @@ +import Flutter + +// flutter_rust_bridge handles all FFI via dart:ffi. +// This class only exists to satisfy Flutter's plugin registration mechanism. +public class RiftFlutterFfiPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) {} +} diff --git a/client/mobile/flutter_dart_wrappers/ios/rift_flutter_ffi.podspec b/client/mobile/flutter_dart_wrappers/ios/rift_flutter_ffi.podspec new file mode 100644 index 00000000..9a5b3040 --- /dev/null +++ b/client/mobile/flutter_dart_wrappers/ios/rift_flutter_ffi.podspec @@ -0,0 +1,24 @@ +Pod::Spec.new do |s| + s.name = 'rift_flutter_ffi' + s.version = '0.2.0' + s.summary = 'Rift deep link SDK for Flutter' + s.description = 'Attribution, deferred deep linking, and conversion tracking via Rift.' + s.homepage = 'https://riftl.ink' + s.license = { type: 'MIT', file: '../LICENSE' } + s.author = { 'Rift' => 'hi@riftl.ink' } + s.source = { path: '.' } + + s.source_files = 'Classes/**/*.swift' + s.swift_version = '5.0' + s.platform = :ios, '12.0' + s.dependency 'Flutter' + + # Prebuilt XCFramework containing the Rust staticlib + C header. + # The release tarball places it alongside this podspec. + s.vendored_frameworks = 'rift_flutter_ffi.xcframework' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + } +end diff --git a/client/mobile/flutter_dart_wrappers/lib/rift_sdk.dart b/client/mobile/flutter_dart_wrappers/lib/rift_sdk.dart new file mode 100644 index 00000000..d705fafb --- /dev/null +++ b/client/mobile/flutter_dart_wrappers/lib/rift_sdk.dart @@ -0,0 +1,21 @@ +/// Rift Flutter SDK — deep link attribution, deferred deep linking, conversion tracking. +/// +/// Quick start: +/// ```dart +/// final rift = await RiftSdk.create(publishableKey: 'pk_live_...'); +/// +/// // Bind user after sign-in +/// await rift.setUserId('user_123'); +/// +/// // Check for deferred deep link on first stable screen (not at launch) +/// final text = (await Clipboard.getData('text/plain'))?.text; +/// final result = await rift.checkDeferredDeepLink(clipboardText: text); +/// if (result != null) { /* navigate */ } +/// +/// // Fire a conversion event +/// await rift.trackConversion(type: 'purchase', idempotencyKey: 'order_xyz'); +/// ``` +library rift_flutter_ffi; + +export 'src/rift_sdk_impl.dart'; +export 'src/rift_storage.dart'; diff --git a/client/mobile/flutter_dart_wrappers/lib/src/rift_sdk_impl.dart b/client/mobile/flutter_dart_wrappers/lib/src/rift_sdk_impl.dart new file mode 100644 index 00000000..f09f55d1 --- /dev/null +++ b/client/mobile/flutter_dart_wrappers/lib/src/rift_sdk_impl.dart @@ -0,0 +1,124 @@ +import 'package:rift_flutter_ffi/src/rift_storage.dart'; +import 'package:rift_flutter_ffi/src/rust/frb_generated.dart'; + +export 'package:rift_flutter_ffi/src/rust/frb_generated.dart' + show ClickResult, GetLinkResult, DeferredDeepLinkResult; + +const _kInstallId = 'rift.install_id'; +const _kUserId = 'rift.user_id'; +const _kUserIdSynced = 'rift.user_id_synced'; + +/// Rift SDK for Flutter. Obtain via [RiftSdk.create]. +/// +/// The SDK owns all attribution logic. The [RiftStorage] implementation you +/// provide owns persistence — the SDK reads initial state from storage at +/// construction and writes back after every state-mutating operation. +class RiftSdk { + final RiftSdkRust _sdk; + final RiftStorage _storage; + + RiftSdk._(this._sdk, this._storage); + + /// Initialize the SDK. Call once at app startup, before calling any other + /// method. Loads persisted state (install_id, user binding) from [storage] + /// and retries any unsynced user binding in the background. + /// + /// [storage] defaults to [SharedPrefsRiftStorage] when omitted. + static Future create({ + required String publishableKey, + RiftStorage? storage, + String? baseUrl, + String? logLevel, + String? appVersion, + }) async { + await RustLib.init(); + + final st = storage ?? await SharedPrefsRiftStorage.create(); + final installId = await st.get(_kInstallId); + final userId = await st.get(_kUserId); + final userIdSynced = await st.get(_kUserIdSynced) == 'true'; + + final sdk = await RiftSdkRust.create( + config: RiftConfig( + publishableKey: publishableKey, + baseUrl: baseUrl, + logLevel: logLevel, + appVersion: appVersion, + ), + state: installId != null + ? RiftState( + installId: installId, + userId: userId, + userIdSynced: userIdSynced, + ) + : null, + ); + + // Persist a newly-generated install_id so it survives the next launch. + final current = sdk.getState(); + if (current.installId != installId) { + await st.set(_kInstallId, current.installId); + } + + return RiftSdk._(sdk, st); + } + + /// The persistent install ID for this device. + String get installId => _sdk.getInstallId(); + + /// Bind a user ID to this install. Call after the user signs in. Safe to + /// call on every launch with the same user_id — it no-ops if already synced. + Future setUserId(String userId) async { + final state = await _sdk.setUserId(userId: userId); + await _persistState(state); + } + + /// Clear the bound user ID. Call on logout. install_id is preserved. + Future clearUserId() async { + final state = _sdk.clearUserId(); + await _persistState(state); + } + + /// Resolve a link and return routing destinations. + Future click(String linkId) => _sdk.click(linkId: linkId); + + /// Report attribution for this install. Returns true on success. + Future attributeLink(String linkId) => _sdk.attributeLink(linkId: linkId); + + /// Fetch link routing destinations without recording a click. + Future getLink(String linkId) => _sdk.getLink(linkId: linkId); + + /// Parse clipboard text for a Rift link, report attribution, and return link + /// data for navigation. Returns null if no Rift link is found. + /// + /// Call this on your first stable screen (home/dashboard), NOT at cold start + /// while the user is still in onboarding. + Future checkDeferredDeepLink({ + required String? clipboardText, + }) => + _sdk.checkDeferredDeepLink(clipboardText: clipboardText); + + /// Fire a conversion event (purchase, signup, etc.). No-op if no user is bound. + /// + /// [metadata] is an optional JSON string of arbitrary key-value pairs. + Future trackConversion({ + required String type, + required String idempotencyKey, + String? metadata, + }) => + _sdk.trackConversion( + conversionType: type, + idempotencyKey: idempotencyKey, + metadata: metadata, + ); + + Future _persistState(RiftState state) async { + await _storage.set(_kInstallId, state.installId); + if (state.userId != null) { + await _storage.set(_kUserId, state.userId!); + } else { + await _storage.remove(_kUserId); + } + await _storage.set(_kUserIdSynced, state.userIdSynced ? 'true' : 'false'); + } +} diff --git a/client/mobile/flutter_dart_wrappers/lib/src/rift_storage.dart b/client/mobile/flutter_dart_wrappers/lib/src/rift_storage.dart new file mode 100644 index 00000000..96d39858 --- /dev/null +++ b/client/mobile/flutter_dart_wrappers/lib/src/rift_storage.dart @@ -0,0 +1,39 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Storage backend contract. Implement this to use your preferred storage +/// (FlutterSecureStorage, Hive, encrypted SharedPreferences, etc.). +/// +/// The default implementation, [SharedPrefsRiftStorage], uses +/// `shared_preferences` and is suitable for most apps. On Android this +/// survives app updates but not reinstalls. On iOS use [FlutterSecureStorage] +/// for Keychain-backed persistence across reinstalls. +abstract class RiftStorage { + Future get(String key); + Future set(String key, String value); + Future remove(String key); +} + +/// Default storage backed by `shared_preferences`. +class SharedPrefsRiftStorage implements RiftStorage { + final SharedPreferences _prefs; + + const SharedPrefsRiftStorage(this._prefs); + + static Future create() async { + final prefs = await SharedPreferences.getInstance(); + return SharedPrefsRiftStorage(prefs); + } + + @override + Future get(String key) async => _prefs.getString(key); + + @override + Future set(String key, String value) async { + await _prefs.setString(key, value); + } + + @override + Future remove(String key) async { + await _prefs.remove(key); + } +} diff --git a/client/mobile/flutter_dart_wrappers/pubspec.yaml b/client/mobile/flutter_dart_wrappers/pubspec.yaml new file mode 100644 index 00000000..8aa46795 --- /dev/null +++ b/client/mobile/flutter_dart_wrappers/pubspec.yaml @@ -0,0 +1,25 @@ +name: rift_flutter_ffi +description: Rift deep link SDK for Flutter — attribution, deferred deep linking, conversion tracking. +version: 0.2.0 +homepage: https://riftl.ink + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.10.0' + +flutter: + plugin: + platforms: + android: + ffiPlugin: true + ios: + ffiPlugin: true + +dependencies: + flutter: + sdk: flutter + shared_preferences: ^2.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/client/mobile/flutter_ffi/Cargo.toml b/client/mobile/flutter_ffi/Cargo.toml new file mode 100644 index 00000000..7b4f96ca --- /dev/null +++ b/client/mobile/flutter_ffi/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "rift_flutter_ffi" +version = "0.2.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib"] + +[dependencies] +rift_sdk_core = { path = "../core" } +flutter_rust_bridge = "2" +tokio = { version = "1", features = ["rt", "rt-multi-thread"] } +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } diff --git a/client/mobile/flutter_ffi/build.rs b/client/mobile/flutter_ffi/build.rs new file mode 100644 index 00000000..8febff10 --- /dev/null +++ b/client/mobile/flutter_ffi/build.rs @@ -0,0 +1,6 @@ +fn main() { + // flutter_rust_bridge's proc macros emit `cfg(frb_expand)` internally. + // Declaring it here avoids the "unexpected cfg condition" warning when + // building without having run `flutter_rust_bridge_codegen generate` first. + println!("cargo::rustc-check-cfg=cfg(frb_expand)"); +} diff --git a/client/mobile/flutter_ffi/src/lib.rs b/client/mobile/flutter_ffi/src/lib.rs new file mode 100644 index 00000000..fd52c147 --- /dev/null +++ b/client/mobile/flutter_ffi/src/lib.rs @@ -0,0 +1,431 @@ +use flutter_rust_bridge::frb; +use rift_sdk_core::client::RiftClient; +use rift_sdk_core::error::RiftError as CoreError; +use std::sync::{Arc, Mutex, Once}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +// ── Init ── + +static LOGGING_INIT: Once = Once::new(); + +#[frb(init)] +pub fn init_app() { + // flutter_rust_bridge manages the async runtime internally via the generated + // bridge code. User initialization (logging, etc.) happens in RiftSdk::create(). +} + +fn init_logging(level: &str) { + LOGGING_INIT.call_once(|| { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level)); + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_file(false) + .with_line_number(true) + .with_thread_ids(true) + .with_target(true) + .compact(); + let result = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .try_init(); + match result { + Ok(_) => eprintln!("[Rift Flutter SDK] Logging initialized (level: {level})"), + Err(e) => eprintln!("[Rift Flutter SDK] Logging init skipped: {e}"), + } + }); +} + +// ── Error ── + +#[derive(Debug, thiserror::Error)] +pub enum RiftError { + #[error("Network error: {message}")] + Network { message: String }, + + #[error("API error ({status}): {message}")] + Api { status: u16, message: String }, + + #[error("Deserialization error: {message}")] + Deserialize { message: String }, +} + +impl From for RiftError { + fn from(e: CoreError) -> Self { + match e { + CoreError::Network(msg) => RiftError::Network { message: msg }, + CoreError::Api { status, message } => RiftError::Api { status, message }, + CoreError::Deserialize(msg) => RiftError::Deserialize { message: msg }, + } + } +} + +// ── Config + State ── + +pub struct RiftConfig { + pub publishable_key: String, + pub base_url: Option, + /// "trace" | "debug" | "info" | "warn" | "error". Default: "info". + pub log_level: Option, + /// App version string (e.g. "1.2.3"). Defaults to "unknown". + pub app_version: Option, +} + +/// Snapshot of SDK state that the Dart layer must persist across launches. +/// The Dart wrapper loads this from storage before calling `RiftSdk.create` +/// and writes it back whenever a state-mutating method returns a new snapshot. +pub struct RiftState { + pub install_id: String, + pub user_id: Option, + pub user_id_synced: bool, +} + +// ── Response types ── + +pub struct ClickResult { + pub link_id: String, + pub platform: String, + pub ios_deep_link: Option, + pub android_deep_link: Option, + pub web_url: Option, + pub ios_store_url: Option, + pub android_store_url: Option, + /// JSON string of arbitrary link metadata, or None. + pub metadata: Option, +} + +pub struct GetLinkResult { + pub link_id: String, + pub ios_deep_link: Option, + pub android_deep_link: Option, + pub web_url: Option, + pub ios_store_url: Option, + pub android_store_url: Option, + pub metadata: Option, +} + +pub struct DeferredDeepLinkResult { + pub link_id: String, + pub ios_deep_link: Option, + pub android_deep_link: Option, + pub web_url: Option, + pub metadata: Option, +} + +// ── SDK ── + +struct Inner { + client: RiftClient, + install_id: Mutex, + user_id: Mutex>, + user_id_synced: Mutex, + api_base_url: String, + publishable_key: String, + app_version: String, +} + +/// Main Rift SDK object. Obtain via `RiftSdk.create(...)`. +/// +/// This crate uses flutter_rust_bridge for Dart/Flutter bindings. +/// Storage is owned by the Dart layer: load `RiftState` from your preferred +/// storage backend before calling `create`, and persist the returned `RiftState` +/// after any call that mutates state (`setUserId`, `clearUserId`). +#[frb(opaque)] +pub struct RiftSdk { + inner: Arc, +} + +impl RiftSdk { + /// Construct a new SDK instance. Load persisted state from Dart storage and + /// pass it here; if `None` (first launch), a new install_id is generated. + /// Check `getState().install_id` after construction and persist if it changed. + pub async fn create(config: RiftConfig, state: Option) -> RiftSdk { + let level = config.log_level.as_deref().unwrap_or("info"); + init_logging(level); + + let install_id = state + .as_ref() + .map(|s| s.install_id.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + let user_id = state.as_ref().and_then(|s| s.user_id.clone()); + let user_id_synced = state.as_ref().map(|s| s.user_id_synced).unwrap_or(false); + + let api_base_url = config + .base_url + .clone() + .unwrap_or_else(|| "https://api.riftl.ink".to_string()); + let publishable_key = config.publishable_key.clone(); + + let inner = Arc::new(Inner { + client: RiftClient::new(config.publishable_key, config.base_url), + install_id: Mutex::new(install_id), + user_id: Mutex::new(user_id), + user_id_synced: Mutex::new(user_id_synced), + api_base_url, + publishable_key, + app_version: config.app_version.unwrap_or_else(|| "unknown".to_string()), + }); + + // Retry pending user binding from a previous session. + let needs_retry = matches!( + ( + inner.user_id.lock().unwrap().clone(), + *inner.user_id_synced.lock().unwrap(), + ), + (Some(_), false) + ); + if needs_retry { + let inner_bg = Arc::clone(&inner); + tokio::spawn(async move { + if let Err(e) = retry_pending_binding(&inner_bg).await { + tracing::debug!(error = ?e, "pending binding retry failed"); + } + }); + } + + RiftSdk { inner } + } + + /// Current SDK state snapshot. Persist this after construction to save a + /// newly-generated install_id, and after any state-mutating call. + #[frb(sync)] + pub fn get_state(&self) -> RiftState { + snapshot(&self.inner) + } + + /// The persistent install ID. Stable across launches; on iOS (with + /// KeychainStorage) stable across reinstalls. + #[frb(sync)] + pub fn get_install_id(&self) -> String { + self.inner.install_id.lock().unwrap().clone() + } + + /// Bind a user ID to this install. Persists locally and calls the server. + /// On network failure the binding is left as unsynced and retried on the + /// next `create` call. Returns the new state to persist. + pub async fn set_user_id(&self, user_id: String) -> Result { + if user_id.trim().is_empty() { + return Err(RiftError::Api { + status: 400, + message: "user_id must not be empty".to_string(), + }); + } + + let existing = self.inner.user_id.lock().unwrap().clone(); + let synced = *self.inner.user_id_synced.lock().unwrap(); + if existing.as_deref() == Some(user_id.as_str()) && synced { + return Ok(snapshot(&self.inner)); + } + + *self.inner.user_id.lock().unwrap() = Some(user_id.clone()); + *self.inner.user_id_synced.lock().unwrap() = false; + + let install_id = self.inner.install_id.lock().unwrap().clone(); + match self + .inner + .client + .identify(install_id, user_id.clone()) + .await + { + Ok(_) => { + *self.inner.user_id_synced.lock().unwrap() = true; + Ok(snapshot(&self.inner)) + } + Err(CoreError::Api { status, .. }) if status == 400 || status == 404 => { + tracing::warn!( + status, + "identify permanently rejected; clearing pending state" + ); + *self.inner.user_id.lock().unwrap() = None; + *self.inner.user_id_synced.lock().unwrap() = false; + Err(RiftError::Api { + status, + message: "User binding rejected by server".to_string(), + }) + } + Err(e) => { + tracing::warn!(error = ?e, "identify failed; will retry on next launch"); + Err(e.into()) + } + } + } + + /// Clear the bound user ID (call on logout). Returns the new state to persist. + #[frb(sync)] + pub fn clear_user_id(&self) -> RiftState { + *self.inner.user_id.lock().unwrap() = None; + *self.inner.user_id_synced.lock().unwrap() = false; + snapshot(&self.inner) + } + + /// Resolve a link and return routing destinations. + pub async fn click(&self, link_id: String) -> Result { + let resp = self.inner.client.click(link_id).await?; + Ok(ClickResult { + link_id: resp.link_id, + platform: resp.platform, + ios_deep_link: resp.ios_deep_link, + android_deep_link: resp.android_deep_link, + web_url: resp.web_url, + ios_store_url: resp.ios_store_url, + android_store_url: resp.android_store_url, + metadata: resp.metadata.map(|v| v.to_string()), + }) + } + + /// Report attribution for this install. Returns `true` on success. + pub async fn attribute_link(&self, link_id: String) -> Result { + let install_id = self.inner.install_id.lock().unwrap().clone(); + let app_version = self.inner.app_version.clone(); + Ok(self + .inner + .client + .attribute(link_id, install_id, app_version) + .await?) + } + + /// Fetch link routing destinations without recording a click. + pub async fn get_link(&self, link_id: String) -> Result { + let resp = self.inner.client.get_link(link_id).await?; + Ok(GetLinkResult { + link_id: resp.link_id, + ios_deep_link: resp.ios_deep_link, + android_deep_link: resp.android_deep_link, + web_url: resp.web_url, + ios_store_url: resp.ios_store_url, + android_store_url: resp.android_store_url, + metadata: resp.metadata.map(|v| v.to_string()), + }) + } + + /// Parse clipboard text for a Rift link, report attribution if found, and + /// return link data for navigation. Pass `None` if clipboard is empty. + /// + /// The caller must read the clipboard themselves — the SDK does not request + /// clipboard permission directly. + pub async fn check_deferred_deep_link( + &self, + clipboard_text: Option, + ) -> Result, RiftError> { + let Some(text) = clipboard_text else { + return Ok(None); + }; + let Some(link_id) = rift_sdk_core::parser::parse_clipboard_link(&text) else { + return Ok(None); + }; + + if let Err(e) = self.attribute_link(link_id.clone()).await { + tracing::warn!(error = ?e, "deferred deep link attribution failed"); + } + + match self.inner.client.get_link(link_id.clone()).await { + Ok(resp) => Ok(Some(DeferredDeepLinkResult { + link_id, + ios_deep_link: resp.ios_deep_link, + android_deep_link: resp.android_deep_link, + web_url: resp.web_url, + metadata: resp.metadata.map(|v| v.to_string()), + })), + Err(e) => { + tracing::warn!(error = ?e, "deferred deep link fetch failed"); + Err(e.into()) + } + } + } + + /// Fire a conversion event. No-op (with a warning) if no user_id is bound. + pub async fn track_conversion( + &self, + conversion_type: String, + idempotency_key: String, + metadata: Option, + ) -> Result<(), RiftError> { + let user_id = self.inner.user_id.lock().unwrap().clone(); + let Some(user_id) = user_id else { + tracing::warn!("track_conversion called but no user_id bound — call setUserId first"); + return Ok(()); + }; + + let mut payload = serde_json::json!({ + "user_id": user_id, + "type": conversion_type, + "idempotency_key": idempotency_key, + }); + if let Some(meta_str) = metadata { + if let Ok(meta_val) = serde_json::from_str::(&meta_str) { + payload["metadata"] = meta_val; + } + } + + let url = format!( + "{}/v1/lifecycle/convert", + self.inner.api_base_url.trim_end_matches('/') + ); + let http = reqwest::Client::new(); + match http + .post(&url) + .header( + "Authorization", + format!("Bearer {}", self.inner.publishable_key), + ) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + { + Ok(r) => tracing::debug!(status = %r.status(), "conversion event sent"), + Err(e) => tracing::warn!(error = %e, "conversion event failed"), + } + + Ok(()) + } +} + +// ── Free functions ── + +/// Extract a Rift link_id from a clipboard string (URL or "rift:" format). +pub fn parse_clipboard_link(text: String) -> Option { + rift_sdk_core::parser::parse_clipboard_link(&text) +} + +/// Extract a Rift link_id from an Android install referrer query string. +pub fn parse_referrer_link(referrer: String) -> Option { + rift_sdk_core::parser::parse_referrer_link(&referrer) +} + +// ── Helpers ── + +fn snapshot(inner: &Inner) -> RiftState { + RiftState { + install_id: inner.install_id.lock().unwrap().clone(), + user_id: inner.user_id.lock().unwrap().clone(), + user_id_synced: *inner.user_id_synced.lock().unwrap(), + } +} + +async fn retry_pending_binding(inner: &Inner) -> Result<(), RiftError> { + let user_id = inner.user_id.lock().unwrap().clone(); + let synced = *inner.user_id_synced.lock().unwrap(); + + let Some(user_id) = user_id else { + return Ok(()); + }; + if synced { + return Ok(()); + } + + let install_id = inner.install_id.lock().unwrap().clone(); + inner.client.identify(install_id, user_id.clone()).await?; + + // Only mark synced if the stored user_id is still the same (guard against + // a concurrent set_user_id call). + let current = inner.user_id.lock().unwrap().clone(); + if current.as_deref() == Some(user_id.as_str()) { + *inner.user_id_synced.lock().unwrap() = true; + } + + Ok(()) +} From 3f317c3a3f3ac4c9fcee9a0071d23a031349abd5 Mon Sep 17 00:00:00 2001 From: drei Date: Mon, 25 May 2026 15:25:47 -0500 Subject: [PATCH 2/9] Fix: update frb codegen to v2 module-path syntax flutter_rust_bridge_codegen v2.12 dropped file-glob rust_input in favor of Rust module paths. Move the API to src/api.rs and use --rust-input crate::api --rust-root flutter_ffi/ everywhere. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mobile-sdk-ci.yml | 3 +- client/mobile/build_flutter.sh | 3 +- client/mobile/flutter_ffi/src/api.rs | 431 ++++++++++++++++++++++++++ client/mobile/flutter_ffi/src/lib.rs | 433 +-------------------------- 4 files changed, 437 insertions(+), 433 deletions(-) create mode 100644 client/mobile/flutter_ffi/src/api.rs diff --git a/.github/workflows/mobile-sdk-ci.yml b/.github/workflows/mobile-sdk-ci.yml index 7defff2e..4cbadd45 100644 --- a/.github/workflows/mobile-sdk-ci.yml +++ b/.github/workflows/mobile-sdk-ci.yml @@ -62,7 +62,8 @@ jobs: run: | mkdir -p dist/flutter/lib/src/rust dist/flutter/headers flutter_rust_bridge_codegen generate \ - --rust-input flutter_ffi/src/lib.rs \ + --rust-input crate::api \ + --rust-root flutter_ffi/ \ --dart-output dist/flutter/lib/src/rust/frb_generated.dart \ --c-output dist/flutter/headers/rift_flutter_ffi.h \ --no-web diff --git a/client/mobile/build_flutter.sh b/client/mobile/build_flutter.sh index 6f36ac9f..018e19f9 100755 --- a/client/mobile/build_flutter.sh +++ b/client/mobile/build_flutter.sh @@ -25,7 +25,8 @@ fi # frb codegen reads the Rust source and emits Dart + C header. flutter_rust_bridge_codegen generate \ - --rust-input "flutter_ffi/src/lib.rs" \ + --rust-input "crate::api" \ + --rust-root "flutter_ffi/" \ --dart-output "$DIST/lib/src/rust/frb_generated.dart" \ --c-output "$HEADERS_DIR/${CRATE}.h" \ --no-web diff --git a/client/mobile/flutter_ffi/src/api.rs b/client/mobile/flutter_ffi/src/api.rs new file mode 100644 index 00000000..fd52c147 --- /dev/null +++ b/client/mobile/flutter_ffi/src/api.rs @@ -0,0 +1,431 @@ +use flutter_rust_bridge::frb; +use rift_sdk_core::client::RiftClient; +use rift_sdk_core::error::RiftError as CoreError; +use std::sync::{Arc, Mutex, Once}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +// ── Init ── + +static LOGGING_INIT: Once = Once::new(); + +#[frb(init)] +pub fn init_app() { + // flutter_rust_bridge manages the async runtime internally via the generated + // bridge code. User initialization (logging, etc.) happens in RiftSdk::create(). +} + +fn init_logging(level: &str) { + LOGGING_INIT.call_once(|| { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level)); + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_file(false) + .with_line_number(true) + .with_thread_ids(true) + .with_target(true) + .compact(); + let result = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .try_init(); + match result { + Ok(_) => eprintln!("[Rift Flutter SDK] Logging initialized (level: {level})"), + Err(e) => eprintln!("[Rift Flutter SDK] Logging init skipped: {e}"), + } + }); +} + +// ── Error ── + +#[derive(Debug, thiserror::Error)] +pub enum RiftError { + #[error("Network error: {message}")] + Network { message: String }, + + #[error("API error ({status}): {message}")] + Api { status: u16, message: String }, + + #[error("Deserialization error: {message}")] + Deserialize { message: String }, +} + +impl From for RiftError { + fn from(e: CoreError) -> Self { + match e { + CoreError::Network(msg) => RiftError::Network { message: msg }, + CoreError::Api { status, message } => RiftError::Api { status, message }, + CoreError::Deserialize(msg) => RiftError::Deserialize { message: msg }, + } + } +} + +// ── Config + State ── + +pub struct RiftConfig { + pub publishable_key: String, + pub base_url: Option, + /// "trace" | "debug" | "info" | "warn" | "error". Default: "info". + pub log_level: Option, + /// App version string (e.g. "1.2.3"). Defaults to "unknown". + pub app_version: Option, +} + +/// Snapshot of SDK state that the Dart layer must persist across launches. +/// The Dart wrapper loads this from storage before calling `RiftSdk.create` +/// and writes it back whenever a state-mutating method returns a new snapshot. +pub struct RiftState { + pub install_id: String, + pub user_id: Option, + pub user_id_synced: bool, +} + +// ── Response types ── + +pub struct ClickResult { + pub link_id: String, + pub platform: String, + pub ios_deep_link: Option, + pub android_deep_link: Option, + pub web_url: Option, + pub ios_store_url: Option, + pub android_store_url: Option, + /// JSON string of arbitrary link metadata, or None. + pub metadata: Option, +} + +pub struct GetLinkResult { + pub link_id: String, + pub ios_deep_link: Option, + pub android_deep_link: Option, + pub web_url: Option, + pub ios_store_url: Option, + pub android_store_url: Option, + pub metadata: Option, +} + +pub struct DeferredDeepLinkResult { + pub link_id: String, + pub ios_deep_link: Option, + pub android_deep_link: Option, + pub web_url: Option, + pub metadata: Option, +} + +// ── SDK ── + +struct Inner { + client: RiftClient, + install_id: Mutex, + user_id: Mutex>, + user_id_synced: Mutex, + api_base_url: String, + publishable_key: String, + app_version: String, +} + +/// Main Rift SDK object. Obtain via `RiftSdk.create(...)`. +/// +/// This crate uses flutter_rust_bridge for Dart/Flutter bindings. +/// Storage is owned by the Dart layer: load `RiftState` from your preferred +/// storage backend before calling `create`, and persist the returned `RiftState` +/// after any call that mutates state (`setUserId`, `clearUserId`). +#[frb(opaque)] +pub struct RiftSdk { + inner: Arc, +} + +impl RiftSdk { + /// Construct a new SDK instance. Load persisted state from Dart storage and + /// pass it here; if `None` (first launch), a new install_id is generated. + /// Check `getState().install_id` after construction and persist if it changed. + pub async fn create(config: RiftConfig, state: Option) -> RiftSdk { + let level = config.log_level.as_deref().unwrap_or("info"); + init_logging(level); + + let install_id = state + .as_ref() + .map(|s| s.install_id.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + let user_id = state.as_ref().and_then(|s| s.user_id.clone()); + let user_id_synced = state.as_ref().map(|s| s.user_id_synced).unwrap_or(false); + + let api_base_url = config + .base_url + .clone() + .unwrap_or_else(|| "https://api.riftl.ink".to_string()); + let publishable_key = config.publishable_key.clone(); + + let inner = Arc::new(Inner { + client: RiftClient::new(config.publishable_key, config.base_url), + install_id: Mutex::new(install_id), + user_id: Mutex::new(user_id), + user_id_synced: Mutex::new(user_id_synced), + api_base_url, + publishable_key, + app_version: config.app_version.unwrap_or_else(|| "unknown".to_string()), + }); + + // Retry pending user binding from a previous session. + let needs_retry = matches!( + ( + inner.user_id.lock().unwrap().clone(), + *inner.user_id_synced.lock().unwrap(), + ), + (Some(_), false) + ); + if needs_retry { + let inner_bg = Arc::clone(&inner); + tokio::spawn(async move { + if let Err(e) = retry_pending_binding(&inner_bg).await { + tracing::debug!(error = ?e, "pending binding retry failed"); + } + }); + } + + RiftSdk { inner } + } + + /// Current SDK state snapshot. Persist this after construction to save a + /// newly-generated install_id, and after any state-mutating call. + #[frb(sync)] + pub fn get_state(&self) -> RiftState { + snapshot(&self.inner) + } + + /// The persistent install ID. Stable across launches; on iOS (with + /// KeychainStorage) stable across reinstalls. + #[frb(sync)] + pub fn get_install_id(&self) -> String { + self.inner.install_id.lock().unwrap().clone() + } + + /// Bind a user ID to this install. Persists locally and calls the server. + /// On network failure the binding is left as unsynced and retried on the + /// next `create` call. Returns the new state to persist. + pub async fn set_user_id(&self, user_id: String) -> Result { + if user_id.trim().is_empty() { + return Err(RiftError::Api { + status: 400, + message: "user_id must not be empty".to_string(), + }); + } + + let existing = self.inner.user_id.lock().unwrap().clone(); + let synced = *self.inner.user_id_synced.lock().unwrap(); + if existing.as_deref() == Some(user_id.as_str()) && synced { + return Ok(snapshot(&self.inner)); + } + + *self.inner.user_id.lock().unwrap() = Some(user_id.clone()); + *self.inner.user_id_synced.lock().unwrap() = false; + + let install_id = self.inner.install_id.lock().unwrap().clone(); + match self + .inner + .client + .identify(install_id, user_id.clone()) + .await + { + Ok(_) => { + *self.inner.user_id_synced.lock().unwrap() = true; + Ok(snapshot(&self.inner)) + } + Err(CoreError::Api { status, .. }) if status == 400 || status == 404 => { + tracing::warn!( + status, + "identify permanently rejected; clearing pending state" + ); + *self.inner.user_id.lock().unwrap() = None; + *self.inner.user_id_synced.lock().unwrap() = false; + Err(RiftError::Api { + status, + message: "User binding rejected by server".to_string(), + }) + } + Err(e) => { + tracing::warn!(error = ?e, "identify failed; will retry on next launch"); + Err(e.into()) + } + } + } + + /// Clear the bound user ID (call on logout). Returns the new state to persist. + #[frb(sync)] + pub fn clear_user_id(&self) -> RiftState { + *self.inner.user_id.lock().unwrap() = None; + *self.inner.user_id_synced.lock().unwrap() = false; + snapshot(&self.inner) + } + + /// Resolve a link and return routing destinations. + pub async fn click(&self, link_id: String) -> Result { + let resp = self.inner.client.click(link_id).await?; + Ok(ClickResult { + link_id: resp.link_id, + platform: resp.platform, + ios_deep_link: resp.ios_deep_link, + android_deep_link: resp.android_deep_link, + web_url: resp.web_url, + ios_store_url: resp.ios_store_url, + android_store_url: resp.android_store_url, + metadata: resp.metadata.map(|v| v.to_string()), + }) + } + + /// Report attribution for this install. Returns `true` on success. + pub async fn attribute_link(&self, link_id: String) -> Result { + let install_id = self.inner.install_id.lock().unwrap().clone(); + let app_version = self.inner.app_version.clone(); + Ok(self + .inner + .client + .attribute(link_id, install_id, app_version) + .await?) + } + + /// Fetch link routing destinations without recording a click. + pub async fn get_link(&self, link_id: String) -> Result { + let resp = self.inner.client.get_link(link_id).await?; + Ok(GetLinkResult { + link_id: resp.link_id, + ios_deep_link: resp.ios_deep_link, + android_deep_link: resp.android_deep_link, + web_url: resp.web_url, + ios_store_url: resp.ios_store_url, + android_store_url: resp.android_store_url, + metadata: resp.metadata.map(|v| v.to_string()), + }) + } + + /// Parse clipboard text for a Rift link, report attribution if found, and + /// return link data for navigation. Pass `None` if clipboard is empty. + /// + /// The caller must read the clipboard themselves — the SDK does not request + /// clipboard permission directly. + pub async fn check_deferred_deep_link( + &self, + clipboard_text: Option, + ) -> Result, RiftError> { + let Some(text) = clipboard_text else { + return Ok(None); + }; + let Some(link_id) = rift_sdk_core::parser::parse_clipboard_link(&text) else { + return Ok(None); + }; + + if let Err(e) = self.attribute_link(link_id.clone()).await { + tracing::warn!(error = ?e, "deferred deep link attribution failed"); + } + + match self.inner.client.get_link(link_id.clone()).await { + Ok(resp) => Ok(Some(DeferredDeepLinkResult { + link_id, + ios_deep_link: resp.ios_deep_link, + android_deep_link: resp.android_deep_link, + web_url: resp.web_url, + metadata: resp.metadata.map(|v| v.to_string()), + })), + Err(e) => { + tracing::warn!(error = ?e, "deferred deep link fetch failed"); + Err(e.into()) + } + } + } + + /// Fire a conversion event. No-op (with a warning) if no user_id is bound. + pub async fn track_conversion( + &self, + conversion_type: String, + idempotency_key: String, + metadata: Option, + ) -> Result<(), RiftError> { + let user_id = self.inner.user_id.lock().unwrap().clone(); + let Some(user_id) = user_id else { + tracing::warn!("track_conversion called but no user_id bound — call setUserId first"); + return Ok(()); + }; + + let mut payload = serde_json::json!({ + "user_id": user_id, + "type": conversion_type, + "idempotency_key": idempotency_key, + }); + if let Some(meta_str) = metadata { + if let Ok(meta_val) = serde_json::from_str::(&meta_str) { + payload["metadata"] = meta_val; + } + } + + let url = format!( + "{}/v1/lifecycle/convert", + self.inner.api_base_url.trim_end_matches('/') + ); + let http = reqwest::Client::new(); + match http + .post(&url) + .header( + "Authorization", + format!("Bearer {}", self.inner.publishable_key), + ) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + { + Ok(r) => tracing::debug!(status = %r.status(), "conversion event sent"), + Err(e) => tracing::warn!(error = %e, "conversion event failed"), + } + + Ok(()) + } +} + +// ── Free functions ── + +/// Extract a Rift link_id from a clipboard string (URL or "rift:" format). +pub fn parse_clipboard_link(text: String) -> Option { + rift_sdk_core::parser::parse_clipboard_link(&text) +} + +/// Extract a Rift link_id from an Android install referrer query string. +pub fn parse_referrer_link(referrer: String) -> Option { + rift_sdk_core::parser::parse_referrer_link(&referrer) +} + +// ── Helpers ── + +fn snapshot(inner: &Inner) -> RiftState { + RiftState { + install_id: inner.install_id.lock().unwrap().clone(), + user_id: inner.user_id.lock().unwrap().clone(), + user_id_synced: *inner.user_id_synced.lock().unwrap(), + } +} + +async fn retry_pending_binding(inner: &Inner) -> Result<(), RiftError> { + let user_id = inner.user_id.lock().unwrap().clone(); + let synced = *inner.user_id_synced.lock().unwrap(); + + let Some(user_id) = user_id else { + return Ok(()); + }; + if synced { + return Ok(()); + } + + let install_id = inner.install_id.lock().unwrap().clone(); + inner.client.identify(install_id, user_id.clone()).await?; + + // Only mark synced if the stored user_id is still the same (guard against + // a concurrent set_user_id call). + let current = inner.user_id.lock().unwrap().clone(); + if current.as_deref() == Some(user_id.as_str()) { + *inner.user_id_synced.lock().unwrap() = true; + } + + Ok(()) +} diff --git a/client/mobile/flutter_ffi/src/lib.rs b/client/mobile/flutter_ffi/src/lib.rs index fd52c147..f933c52c 100644 --- a/client/mobile/flutter_ffi/src/lib.rs +++ b/client/mobile/flutter_ffi/src/lib.rs @@ -1,431 +1,2 @@ -use flutter_rust_bridge::frb; -use rift_sdk_core::client::RiftClient; -use rift_sdk_core::error::RiftError as CoreError; -use std::sync::{Arc, Mutex, Once}; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; - -// ── Init ── - -static LOGGING_INIT: Once = Once::new(); - -#[frb(init)] -pub fn init_app() { - // flutter_rust_bridge manages the async runtime internally via the generated - // bridge code. User initialization (logging, etc.) happens in RiftSdk::create(). -} - -fn init_logging(level: &str) { - LOGGING_INIT.call_once(|| { - let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level)); - let fmt_layer = tracing_subscriber::fmt::layer() - .with_ansi(false) - .with_file(false) - .with_line_number(true) - .with_thread_ids(true) - .with_target(true) - .compact(); - let result = tracing_subscriber::registry() - .with(filter) - .with(fmt_layer) - .try_init(); - match result { - Ok(_) => eprintln!("[Rift Flutter SDK] Logging initialized (level: {level})"), - Err(e) => eprintln!("[Rift Flutter SDK] Logging init skipped: {e}"), - } - }); -} - -// ── Error ── - -#[derive(Debug, thiserror::Error)] -pub enum RiftError { - #[error("Network error: {message}")] - Network { message: String }, - - #[error("API error ({status}): {message}")] - Api { status: u16, message: String }, - - #[error("Deserialization error: {message}")] - Deserialize { message: String }, -} - -impl From for RiftError { - fn from(e: CoreError) -> Self { - match e { - CoreError::Network(msg) => RiftError::Network { message: msg }, - CoreError::Api { status, message } => RiftError::Api { status, message }, - CoreError::Deserialize(msg) => RiftError::Deserialize { message: msg }, - } - } -} - -// ── Config + State ── - -pub struct RiftConfig { - pub publishable_key: String, - pub base_url: Option, - /// "trace" | "debug" | "info" | "warn" | "error". Default: "info". - pub log_level: Option, - /// App version string (e.g. "1.2.3"). Defaults to "unknown". - pub app_version: Option, -} - -/// Snapshot of SDK state that the Dart layer must persist across launches. -/// The Dart wrapper loads this from storage before calling `RiftSdk.create` -/// and writes it back whenever a state-mutating method returns a new snapshot. -pub struct RiftState { - pub install_id: String, - pub user_id: Option, - pub user_id_synced: bool, -} - -// ── Response types ── - -pub struct ClickResult { - pub link_id: String, - pub platform: String, - pub ios_deep_link: Option, - pub android_deep_link: Option, - pub web_url: Option, - pub ios_store_url: Option, - pub android_store_url: Option, - /// JSON string of arbitrary link metadata, or None. - pub metadata: Option, -} - -pub struct GetLinkResult { - pub link_id: String, - pub ios_deep_link: Option, - pub android_deep_link: Option, - pub web_url: Option, - pub ios_store_url: Option, - pub android_store_url: Option, - pub metadata: Option, -} - -pub struct DeferredDeepLinkResult { - pub link_id: String, - pub ios_deep_link: Option, - pub android_deep_link: Option, - pub web_url: Option, - pub metadata: Option, -} - -// ── SDK ── - -struct Inner { - client: RiftClient, - install_id: Mutex, - user_id: Mutex>, - user_id_synced: Mutex, - api_base_url: String, - publishable_key: String, - app_version: String, -} - -/// Main Rift SDK object. Obtain via `RiftSdk.create(...)`. -/// -/// This crate uses flutter_rust_bridge for Dart/Flutter bindings. -/// Storage is owned by the Dart layer: load `RiftState` from your preferred -/// storage backend before calling `create`, and persist the returned `RiftState` -/// after any call that mutates state (`setUserId`, `clearUserId`). -#[frb(opaque)] -pub struct RiftSdk { - inner: Arc, -} - -impl RiftSdk { - /// Construct a new SDK instance. Load persisted state from Dart storage and - /// pass it here; if `None` (first launch), a new install_id is generated. - /// Check `getState().install_id` after construction and persist if it changed. - pub async fn create(config: RiftConfig, state: Option) -> RiftSdk { - let level = config.log_level.as_deref().unwrap_or("info"); - init_logging(level); - - let install_id = state - .as_ref() - .map(|s| s.install_id.clone()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - - let user_id = state.as_ref().and_then(|s| s.user_id.clone()); - let user_id_synced = state.as_ref().map(|s| s.user_id_synced).unwrap_or(false); - - let api_base_url = config - .base_url - .clone() - .unwrap_or_else(|| "https://api.riftl.ink".to_string()); - let publishable_key = config.publishable_key.clone(); - - let inner = Arc::new(Inner { - client: RiftClient::new(config.publishable_key, config.base_url), - install_id: Mutex::new(install_id), - user_id: Mutex::new(user_id), - user_id_synced: Mutex::new(user_id_synced), - api_base_url, - publishable_key, - app_version: config.app_version.unwrap_or_else(|| "unknown".to_string()), - }); - - // Retry pending user binding from a previous session. - let needs_retry = matches!( - ( - inner.user_id.lock().unwrap().clone(), - *inner.user_id_synced.lock().unwrap(), - ), - (Some(_), false) - ); - if needs_retry { - let inner_bg = Arc::clone(&inner); - tokio::spawn(async move { - if let Err(e) = retry_pending_binding(&inner_bg).await { - tracing::debug!(error = ?e, "pending binding retry failed"); - } - }); - } - - RiftSdk { inner } - } - - /// Current SDK state snapshot. Persist this after construction to save a - /// newly-generated install_id, and after any state-mutating call. - #[frb(sync)] - pub fn get_state(&self) -> RiftState { - snapshot(&self.inner) - } - - /// The persistent install ID. Stable across launches; on iOS (with - /// KeychainStorage) stable across reinstalls. - #[frb(sync)] - pub fn get_install_id(&self) -> String { - self.inner.install_id.lock().unwrap().clone() - } - - /// Bind a user ID to this install. Persists locally and calls the server. - /// On network failure the binding is left as unsynced and retried on the - /// next `create` call. Returns the new state to persist. - pub async fn set_user_id(&self, user_id: String) -> Result { - if user_id.trim().is_empty() { - return Err(RiftError::Api { - status: 400, - message: "user_id must not be empty".to_string(), - }); - } - - let existing = self.inner.user_id.lock().unwrap().clone(); - let synced = *self.inner.user_id_synced.lock().unwrap(); - if existing.as_deref() == Some(user_id.as_str()) && synced { - return Ok(snapshot(&self.inner)); - } - - *self.inner.user_id.lock().unwrap() = Some(user_id.clone()); - *self.inner.user_id_synced.lock().unwrap() = false; - - let install_id = self.inner.install_id.lock().unwrap().clone(); - match self - .inner - .client - .identify(install_id, user_id.clone()) - .await - { - Ok(_) => { - *self.inner.user_id_synced.lock().unwrap() = true; - Ok(snapshot(&self.inner)) - } - Err(CoreError::Api { status, .. }) if status == 400 || status == 404 => { - tracing::warn!( - status, - "identify permanently rejected; clearing pending state" - ); - *self.inner.user_id.lock().unwrap() = None; - *self.inner.user_id_synced.lock().unwrap() = false; - Err(RiftError::Api { - status, - message: "User binding rejected by server".to_string(), - }) - } - Err(e) => { - tracing::warn!(error = ?e, "identify failed; will retry on next launch"); - Err(e.into()) - } - } - } - - /// Clear the bound user ID (call on logout). Returns the new state to persist. - #[frb(sync)] - pub fn clear_user_id(&self) -> RiftState { - *self.inner.user_id.lock().unwrap() = None; - *self.inner.user_id_synced.lock().unwrap() = false; - snapshot(&self.inner) - } - - /// Resolve a link and return routing destinations. - pub async fn click(&self, link_id: String) -> Result { - let resp = self.inner.client.click(link_id).await?; - Ok(ClickResult { - link_id: resp.link_id, - platform: resp.platform, - ios_deep_link: resp.ios_deep_link, - android_deep_link: resp.android_deep_link, - web_url: resp.web_url, - ios_store_url: resp.ios_store_url, - android_store_url: resp.android_store_url, - metadata: resp.metadata.map(|v| v.to_string()), - }) - } - - /// Report attribution for this install. Returns `true` on success. - pub async fn attribute_link(&self, link_id: String) -> Result { - let install_id = self.inner.install_id.lock().unwrap().clone(); - let app_version = self.inner.app_version.clone(); - Ok(self - .inner - .client - .attribute(link_id, install_id, app_version) - .await?) - } - - /// Fetch link routing destinations without recording a click. - pub async fn get_link(&self, link_id: String) -> Result { - let resp = self.inner.client.get_link(link_id).await?; - Ok(GetLinkResult { - link_id: resp.link_id, - ios_deep_link: resp.ios_deep_link, - android_deep_link: resp.android_deep_link, - web_url: resp.web_url, - ios_store_url: resp.ios_store_url, - android_store_url: resp.android_store_url, - metadata: resp.metadata.map(|v| v.to_string()), - }) - } - - /// Parse clipboard text for a Rift link, report attribution if found, and - /// return link data for navigation. Pass `None` if clipboard is empty. - /// - /// The caller must read the clipboard themselves — the SDK does not request - /// clipboard permission directly. - pub async fn check_deferred_deep_link( - &self, - clipboard_text: Option, - ) -> Result, RiftError> { - let Some(text) = clipboard_text else { - return Ok(None); - }; - let Some(link_id) = rift_sdk_core::parser::parse_clipboard_link(&text) else { - return Ok(None); - }; - - if let Err(e) = self.attribute_link(link_id.clone()).await { - tracing::warn!(error = ?e, "deferred deep link attribution failed"); - } - - match self.inner.client.get_link(link_id.clone()).await { - Ok(resp) => Ok(Some(DeferredDeepLinkResult { - link_id, - ios_deep_link: resp.ios_deep_link, - android_deep_link: resp.android_deep_link, - web_url: resp.web_url, - metadata: resp.metadata.map(|v| v.to_string()), - })), - Err(e) => { - tracing::warn!(error = ?e, "deferred deep link fetch failed"); - Err(e.into()) - } - } - } - - /// Fire a conversion event. No-op (with a warning) if no user_id is bound. - pub async fn track_conversion( - &self, - conversion_type: String, - idempotency_key: String, - metadata: Option, - ) -> Result<(), RiftError> { - let user_id = self.inner.user_id.lock().unwrap().clone(); - let Some(user_id) = user_id else { - tracing::warn!("track_conversion called but no user_id bound — call setUserId first"); - return Ok(()); - }; - - let mut payload = serde_json::json!({ - "user_id": user_id, - "type": conversion_type, - "idempotency_key": idempotency_key, - }); - if let Some(meta_str) = metadata { - if let Ok(meta_val) = serde_json::from_str::(&meta_str) { - payload["metadata"] = meta_val; - } - } - - let url = format!( - "{}/v1/lifecycle/convert", - self.inner.api_base_url.trim_end_matches('/') - ); - let http = reqwest::Client::new(); - match http - .post(&url) - .header( - "Authorization", - format!("Bearer {}", self.inner.publishable_key), - ) - .header("Content-Type", "application/json") - .json(&payload) - .send() - .await - { - Ok(r) => tracing::debug!(status = %r.status(), "conversion event sent"), - Err(e) => tracing::warn!(error = %e, "conversion event failed"), - } - - Ok(()) - } -} - -// ── Free functions ── - -/// Extract a Rift link_id from a clipboard string (URL or "rift:" format). -pub fn parse_clipboard_link(text: String) -> Option { - rift_sdk_core::parser::parse_clipboard_link(&text) -} - -/// Extract a Rift link_id from an Android install referrer query string. -pub fn parse_referrer_link(referrer: String) -> Option { - rift_sdk_core::parser::parse_referrer_link(&referrer) -} - -// ── Helpers ── - -fn snapshot(inner: &Inner) -> RiftState { - RiftState { - install_id: inner.install_id.lock().unwrap().clone(), - user_id: inner.user_id.lock().unwrap().clone(), - user_id_synced: *inner.user_id_synced.lock().unwrap(), - } -} - -async fn retry_pending_binding(inner: &Inner) -> Result<(), RiftError> { - let user_id = inner.user_id.lock().unwrap().clone(); - let synced = *inner.user_id_synced.lock().unwrap(); - - let Some(user_id) = user_id else { - return Ok(()); - }; - if synced { - return Ok(()); - } - - let install_id = inner.install_id.lock().unwrap().clone(); - inner.client.identify(install_id, user_id.clone()).await?; - - // Only mark synced if the stored user_id is still the same (guard against - // a concurrent set_user_id call). - let current = inner.user_id.lock().unwrap().clone(); - if current.as_deref() == Some(user_id.as_str()) { - *inner.user_id_synced.lock().unwrap() = true; - } - - Ok(()) -} +// frb codegen scans `crate::api` for the public API surface. +pub mod api; From 531e7b8395a360e53d9a355dd45cee1e2ba9b0ab Mon Sep 17 00:00:00 2001 From: drei Date: Mon, 25 May 2026 15:28:48 -0500 Subject: [PATCH 3/9] Fix: point frb codegen dart-root at flutter_dart_wrappers/ package frb v2 detects the Dart package by walking up from dart-output to find pubspec.yaml. Generating into dist/ (no pubspec.yaml) fails. Fix: use --dart-root flutter_dart_wrappers/ so frb finds the pubspec, and drop --c-output / --no-web which aren't guaranteed flags in v2.12. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mobile-sdk-ci.yml | 9 ++++----- .github/workflows/mobile-sdk-release.yml | 2 +- client/mobile/build_flutter.sh | 12 +++++++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/mobile-sdk-ci.yml b/.github/workflows/mobile-sdk-ci.yml index 4cbadd45..8397cfd6 100644 --- a/.github/workflows/mobile-sdk-ci.yml +++ b/.github/workflows/mobile-sdk-ci.yml @@ -60,15 +60,14 @@ jobs: - name: Generate Dart bindings run: | - mkdir -p dist/flutter/lib/src/rust dist/flutter/headers + mkdir -p flutter_dart_wrappers/lib/src/rust flutter_rust_bridge_codegen generate \ --rust-input crate::api \ --rust-root flutter_ffi/ \ - --dart-output dist/flutter/lib/src/rust/frb_generated.dart \ - --c-output dist/flutter/headers/rift_flutter_ffi.h \ - --no-web + --dart-root flutter_dart_wrappers/ \ + --dart-output lib/src/rust/frb_generated.dart - name: Verify generated files run: | - test -f dist/flutter/lib/src/rust/frb_generated.dart + test -f flutter_dart_wrappers/lib/src/rust/frb_generated.dart echo "Dart bindings generated successfully" diff --git a/.github/workflows/mobile-sdk-release.yml b/.github/workflows/mobile-sdk-release.yml index dbf2495b..29070925 100644 --- a/.github/workflows/mobile-sdk-release.yml +++ b/.github/workflows/mobile-sdk-release.yml @@ -157,7 +157,7 @@ jobs: test -f dist/flutter/lib/src/rust/frb_generated.dart test -d dist/flutter/ios/rift_flutter_ffi.xcframework test -d dist/flutter/android/jniLibs/arm64-v8a - echo "Flutter artifacts verified" + echo "Flutter artifacts verified — frb_generated.dart, XCFramework, and Android jniLibs all present" - name: Package Flutter SDK run: tar -czf flutter-sdk.tar.gz -C dist flutter diff --git a/client/mobile/build_flutter.sh b/client/mobile/build_flutter.sh index 018e19f9..d1248a76 100755 --- a/client/mobile/build_flutter.sh +++ b/client/mobile/build_flutter.sh @@ -23,15 +23,17 @@ if ! command -v flutter_rust_bridge_codegen &>/dev/null; then cargo install flutter_rust_bridge_codegen fi -# frb codegen reads the Rust source and emits Dart + C header. +# frb codegen reads the Rust source and emits Dart bindings into the +# flutter_dart_wrappers package. The generated file is included when we +# copy flutter_dart_wrappers/ into dist/flutter/ below. +mkdir -p flutter_dart_wrappers/lib/src/rust flutter_rust_bridge_codegen generate \ --rust-input "crate::api" \ --rust-root "flutter_ffi/" \ - --dart-output "$DIST/lib/src/rust/frb_generated.dart" \ - --c-output "$HEADERS_DIR/${CRATE}.h" \ - --no-web + --dart-root "flutter_dart_wrappers/" \ + --dart-output "lib/src/rust/frb_generated.dart" -echo "[Rift] Dart bindings generated → $DIST/lib/src/rust/" +echo "[Rift] Dart bindings generated → flutter_dart_wrappers/lib/src/rust/" # ── Step 2: Build iOS targets ──────────────────────────────────────────────── From 4c8d6a37c5e13ccde63a771824de598d1d5307b2 Mon Sep 17 00:00:00 2001 From: drei Date: Mon, 25 May 2026 15:31:19 -0500 Subject: [PATCH 4/9] Fix: use full path for frb dart-output so package detection succeeds frb resolves --dart-output relative to cwd (not --dart-root), then walks up from that path looking for pubspec.yaml. Using "lib/src/rust/..." resolves inside client/mobile/ with no pubspec there. Using the full path flutter_dart_wrappers/lib/src/rust/frb_generated.dart lets frb find flutter_dart_wrappers/pubspec.yaml correctly. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mobile-sdk-ci.yml | 2 +- client/mobile/build_flutter.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mobile-sdk-ci.yml b/.github/workflows/mobile-sdk-ci.yml index 8397cfd6..6726de82 100644 --- a/.github/workflows/mobile-sdk-ci.yml +++ b/.github/workflows/mobile-sdk-ci.yml @@ -65,7 +65,7 @@ jobs: --rust-input crate::api \ --rust-root flutter_ffi/ \ --dart-root flutter_dart_wrappers/ \ - --dart-output lib/src/rust/frb_generated.dart + --dart-output flutter_dart_wrappers/lib/src/rust/frb_generated.dart - name: Verify generated files run: | diff --git a/client/mobile/build_flutter.sh b/client/mobile/build_flutter.sh index d1248a76..452dc96f 100755 --- a/client/mobile/build_flutter.sh +++ b/client/mobile/build_flutter.sh @@ -31,7 +31,7 @@ flutter_rust_bridge_codegen generate \ --rust-input "crate::api" \ --rust-root "flutter_ffi/" \ --dart-root "flutter_dart_wrappers/" \ - --dart-output "lib/src/rust/frb_generated.dart" + --dart-output "flutter_dart_wrappers/lib/src/rust/frb_generated.dart" echo "[Rift] Dart bindings generated → flutter_dart_wrappers/lib/src/rust/" From a1fafdace9bb326d00c44a3e8fbc2c0d2242c60c Mon Sep 17 00:00:00 2001 From: drei Date: Mon, 25 May 2026 15:33:32 -0500 Subject: [PATCH 5/9] Fix: install Flutter toolchain before running frb codegen flutter_rust_bridge_codegen v2 requires Dart/Flutter to be available to generate Dart bindings (it runs dart analysis internally). Add subosito/flutter-action@v2 to both the CI gen job and the release build-flutter job. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mobile-sdk-ci.yml | 5 +++++ .github/workflows/mobile-sdk-release.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/mobile-sdk-ci.yml b/.github/workflows/mobile-sdk-ci.yml index 6726de82..d368ed72 100644 --- a/.github/workflows/mobile-sdk-ci.yml +++ b/.github/workflows/mobile-sdk-ci.yml @@ -51,6 +51,11 @@ jobs: - uses: dtolnay/rust-toolchain@stable + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.x' + channel: stable + - uses: Swatinem/rust-cache@v2 with: workspaces: client/mobile diff --git a/.github/workflows/mobile-sdk-release.yml b/.github/workflows/mobile-sdk-release.yml index 29070925..a34ac750 100644 --- a/.github/workflows/mobile-sdk-release.yml +++ b/.github/workflows/mobile-sdk-release.yml @@ -142,6 +142,11 @@ jobs: - name: Setup Android NDK uses: android-actions/setup-android@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.x' + channel: stable + - uses: Swatinem/rust-cache@v2 with: workspaces: client/mobile From 6fcd2444ca37129a0181f5b7c71916ab38deefaf Mon Sep 17 00:00:00 2001 From: drei Date: Mon, 25 May 2026 15:38:08 -0500 Subject: [PATCH 6/9] Fix: add frb v2 required deps to flutter pubspec flutter_rust_bridge_codegen v2 requires freezed (Dart codegen backend), freezed_annotation, build_runner, and flutter_rust_bridge as dependencies before it will generate bindings. Co-Authored-By: Claude Sonnet 4.6 --- client/mobile/flutter_dart_wrappers/pubspec.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/mobile/flutter_dart_wrappers/pubspec.yaml b/client/mobile/flutter_dart_wrappers/pubspec.yaml index 8aa46795..59877e3e 100644 --- a/client/mobile/flutter_dart_wrappers/pubspec.yaml +++ b/client/mobile/flutter_dart_wrappers/pubspec.yaml @@ -18,8 +18,13 @@ flutter: dependencies: flutter: sdk: flutter + flutter_rust_bridge: '>=2.0.0 <3.0.0' + freezed_annotation: ^2.4.1 + meta: ^1.11.0 shared_preferences: ^2.2.0 dev_dependencies: flutter_test: sdk: flutter + build_runner: ^2.4.8 + freezed: ^2.4.6 From 458751f5099204fae97a9a2f13f2599cfc5bdf6e Mon Sep 17 00:00:00 2001 From: drei Date: Mon, 25 May 2026 15:43:53 -0500 Subject: [PATCH 7/9] Fix: drop --dart-output; let frb use its default lib/src/rust/ location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frb v2 treats --dart-output as a directory name, not a file path — it was creating frb_generated.dart/ as a directory. Removing the flag lets frb place files in its expected default location inside the dart-root. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mobile-sdk-ci.yml | 3 +-- client/mobile/build_flutter.sh | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/mobile-sdk-ci.yml b/.github/workflows/mobile-sdk-ci.yml index d368ed72..7a6973c1 100644 --- a/.github/workflows/mobile-sdk-ci.yml +++ b/.github/workflows/mobile-sdk-ci.yml @@ -69,8 +69,7 @@ jobs: flutter_rust_bridge_codegen generate \ --rust-input crate::api \ --rust-root flutter_ffi/ \ - --dart-root flutter_dart_wrappers/ \ - --dart-output flutter_dart_wrappers/lib/src/rust/frb_generated.dart + --dart-root flutter_dart_wrappers/ - name: Verify generated files run: | diff --git a/client/mobile/build_flutter.sh b/client/mobile/build_flutter.sh index 452dc96f..054fdc65 100755 --- a/client/mobile/build_flutter.sh +++ b/client/mobile/build_flutter.sh @@ -30,8 +30,7 @@ mkdir -p flutter_dart_wrappers/lib/src/rust flutter_rust_bridge_codegen generate \ --rust-input "crate::api" \ --rust-root "flutter_ffi/" \ - --dart-root "flutter_dart_wrappers/" \ - --dart-output "flutter_dart_wrappers/lib/src/rust/frb_generated.dart" + --dart-root "flutter_dart_wrappers/" echo "[Rift] Dart bindings generated → flutter_dart_wrappers/lib/src/rust/" From ee5e10f24f18685553c54bbf78c865e842b3721e Mon Sep 17 00:00:00 2001 From: drei Date: Mon, 25 May 2026 15:47:58 -0500 Subject: [PATCH 8/9] Fix: pass dart-output as directory path, not file path frb v2 treats --dart-output as a directory: it places frb_generated.dart inside it. Passing the full file path (ending in .dart) caused frb to create a directory named frb_generated.dart. Use the parent dir instead. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mobile-sdk-ci.yml | 3 ++- client/mobile/build_flutter.sh | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mobile-sdk-ci.yml b/.github/workflows/mobile-sdk-ci.yml index 7a6973c1..aee59a01 100644 --- a/.github/workflows/mobile-sdk-ci.yml +++ b/.github/workflows/mobile-sdk-ci.yml @@ -69,7 +69,8 @@ jobs: flutter_rust_bridge_codegen generate \ --rust-input crate::api \ --rust-root flutter_ffi/ \ - --dart-root flutter_dart_wrappers/ + --dart-root flutter_dart_wrappers/ \ + --dart-output flutter_dart_wrappers/lib/src/rust - name: Verify generated files run: | diff --git a/client/mobile/build_flutter.sh b/client/mobile/build_flutter.sh index 054fdc65..9596395c 100755 --- a/client/mobile/build_flutter.sh +++ b/client/mobile/build_flutter.sh @@ -30,7 +30,8 @@ mkdir -p flutter_dart_wrappers/lib/src/rust flutter_rust_bridge_codegen generate \ --rust-input "crate::api" \ --rust-root "flutter_ffi/" \ - --dart-root "flutter_dart_wrappers/" + --dart-root "flutter_dart_wrappers/" \ + --dart-output "flutter_dart_wrappers/lib/src/rust" echo "[Rift] Dart bindings generated → flutter_dart_wrappers/lib/src/rust/" From 4de3e712f84b3c4591bddc28d6553fca68859e1a Mon Sep 17 00:00:00 2001 From: drei Date: Mon, 25 May 2026 16:08:40 -0500 Subject: [PATCH 9/9] Fix: replace declare -A with case statement for bash 3.x compat macOS ships bash 3.2 which doesn't support associative arrays (declare -A, a bash 4+ feature). The build_flutter.sh xcodebuild runner is macOS-15 so this was the crash on line 65. Replace with a plain case statement. Co-Authored-By: Claude Sonnet 4.6 --- client/mobile/build_flutter.sh | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/client/mobile/build_flutter.sh b/client/mobile/build_flutter.sh index 9596395c..913ed45b 100755 --- a/client/mobile/build_flutter.sh +++ b/client/mobile/build_flutter.sh @@ -62,15 +62,13 @@ if ! command -v cargo-ndk &>/dev/null; then cargo install cargo-ndk fi -declare -A ABI_MAP=( - [aarch64-linux-android]="arm64-v8a" - [armv7-linux-androideabi]="armeabi-v7a" - [i686-linux-android]="x86" - [x86_64-linux-android]="x86_64" -) - -for target in "${!ABI_MAP[@]}"; do - abi="${ABI_MAP[$target]}" +for target in aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android; do + case "$target" in + aarch64-linux-android) abi="arm64-v8a" ;; + armv7-linux-androideabi) abi="armeabi-v7a" ;; + i686-linux-android) abi="x86" ;; + x86_64-linux-android) abi="x86_64" ;; + esac echo "[Rift] Building $target ($abi)..." cargo ndk --target "$target" --platform 21 build --release -p "$CRATE" mkdir -p "$ANDROID_DIST/jniLibs/$abi"