diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml
index bbfef14..51dd899 100644
--- a/.github/workflows/mobile-ci.yml
+++ b/.github/workflows/mobile-ci.yml
@@ -38,6 +38,24 @@ jobs:
- name: cargo test -p warden-ffi
run: cargo test --locked -p warden-ffi
+ plugin-check:
+ name: warden_ffi_flutter (analyze + publish dry-run)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ - name: pub get
+ working-directory: ffi/flutter
+ run: flutter pub get
+ - name: analyze
+ working-directory: ffi/flutter
+ run: dart analyze
+ - name: publish dry-run
+ working-directory: ffi/flutter
+ run: flutter pub publish --dry-run
+
build-ios:
name: iOS xcframework (build check)
runs-on: macos-latest
@@ -47,8 +65,15 @@ jobs:
with:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
- uses: Swatinem/rust-cache@v2
- - name: build-mobile.sh ios
- run: ffi/build-mobile.sh ios
+ - name: build-mobile.sh ios-framework (dynamic)
+ run: ffi/build-mobile.sh ios-framework
+ - name: Verify warden_* symbols are exported
+ run: |
+ bin=$(find dist/mobile/ios/WardenFfi.xcframework -path '*ios-arm64/WardenFfi.framework/WardenFfi' | head -1)
+ echo "device framework binary: $bin"
+ n=$(nm -gU "$bin" | grep -c '_warden_' || true)
+ echo "exported warden_* symbols: $n"
+ test "$n" -ge 5
build-android:
name: Android jniLibs (build check)
diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml
index b87a1f6..1571890 100644
--- a/.github/workflows/mobile-release.yml
+++ b/.github/workflows/mobile-release.yml
@@ -33,7 +33,13 @@ jobs:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
- uses: Swatinem/rust-cache@v2
- name: Cross-compile (release, --locked)
- run: ffi/build-mobile.sh ios
+ run: ffi/build-mobile.sh ios-framework
+ - name: Verify warden_* symbols are exported
+ run: |
+ bin=$(find dist/mobile/ios/WardenFfi.xcframework -path '*ios-arm64/WardenFfi.framework/WardenFfi' | head -1)
+ n=$(nm -gU "$bin" | grep -c '_warden_' || true)
+ echo "exported warden_* symbols: $n"
+ test "$n" -ge 5
- name: Zip xcframework
# ditto preserves the framework bundle layout/symlinks better than `zip`.
run: ditto -c -k --keepParent dist/mobile/ios/WardenFfi.xcframework WardenFfi.xcframework.zip
diff --git a/ffi/README.md b/ffi/README.md
index bbf2330..e81890e 100644
--- a/ffi/README.md
+++ b/ffi/README.md
@@ -53,8 +53,12 @@ Each `v*` tag publishes the cross-compiled binaries as **GitHub Release assets**
Pick the release whose version matches the `warden_ffi` pub.dev version you depend on — both are cut
from the same tag, so they're always compatible. A consuming-app redeploy is irrelevant (the gate
-crypto bakes in no on-chain addresses). _(A `warden_ffi_flutter` plugin that does this download
-automatically at build time is tracked in [#4](https://github.com/bytesbrains/warden/issues/4).)_
+crypto bakes in no on-chain addresses).
+
+**Flutter apps: prefer the [`warden_ffi_flutter`](flutter/) plugin** — it does this download
+automatically at build time (iOS embeds a dynamic framework, Android downloads jniLibs), so you add
+one pub.dev dependency and need neither a manual download nor any Xcode/Gradle wiring. The manual
+download above is for non-plugin consumers or for pinning the binary yourself.
### Mobile (build from source — for warden developers)
diff --git a/ffi/build-mobile.sh b/ffi/build-mobile.sh
index c511def..4b790fb 100755
--- a/ffi/build-mobile.sh
+++ b/ffi/build-mobile.sh
@@ -35,13 +35,13 @@ set -euo pipefail
WARDEN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LIB="libwarden_ffi"
-usage() { echo "usage: $0 [ios|android|all] [--out
]" >&2; }
+usage() { echo "usage: $0 [ios|ios-framework|android|all] [--out ]" >&2; }
WHAT=""
OUT_DIR=""
while [ $# -gt 0 ]; do
case "$1" in
- ios|android|all) WHAT="$1"; shift ;;
+ ios|ios-framework|android|all) WHAT="$1"; shift ;;
--out) [ $# -ge 2 ] || { echo "--out requires a directory" >&2; usage; exit 2; }
OUT_DIR="$2"; shift 2 ;;
--out=*) OUT_DIR="${1#*=}"; shift ;;
@@ -82,6 +82,66 @@ build_ios() {
echo "==> iOS: done. Wire it once into ios/Runner per warden/ffi/README.md (§ Mobile → iOS)."
}
+# Wrap a single dylib slice (device, or universal-simulator) in a .framework bundle.
+# The dynamic-framework form is what the warden_ffi_flutter plugin ships: CocoaPods embeds
+# + signs it, so dyld loads it at app launch and the warden_* symbols resolve via
+# DynamicLibrary.process() with NO -force_load wiring on the consumer.
+make_framework() {
+ local dylib="$1" fwdir="$2" minos="$3"
+ rm -rf "$fwdir"; mkdir -p "$fwdir"
+ cp "$dylib" "$fwdir/WardenFfi"
+ # dyld locates the binary by its framework-relative @rpath install name.
+ install_name_tool -id @rpath/WardenFfi.framework/WardenFfi "$fwdir/WardenFfi"
+ cat > "$fwdir/Info.plist" <
+
+
+
+ CFBundleExecutableWardenFfi
+ CFBundleIdentifiercom.bytesbrains.WardenFfi
+ CFBundleInfoDictionaryVersion6.0
+ CFBundleNameWardenFfi
+ CFBundlePackageTypeFMWK
+ CFBundleShortVersionString1.0
+ CFBundleVersion1
+ MinimumOSVersion$minos
+
+
+PLIST
+}
+
+build_ios_framework() {
+ echo "==> iOS (dynamic): cross-compiling $LIB cdylib (device + simulator arm64+x86_64, release, --locked)"
+ ( cd "$WARDEN_DIR" \
+ && cargo build --locked -p warden-ffi --release --target aarch64-apple-ios \
+ && cargo build --locked -p warden-ffi --release --target aarch64-apple-ios-sim \
+ && cargo build --locked -p warden-ffi --release --target x86_64-apple-ios )
+
+ local work="$WARDEN_DIR/target/ios-framework"
+ rm -rf "$work"; mkdir -p "$work/device" "$work/sim"
+
+ # Device: the lone arm64 dylib. Xcode reads the Mach-O platform, so create-xcframework
+ # files this under the ios-arm64 slice automatically.
+ make_framework "$WARDEN_DIR/target/aarch64-apple-ios/release/$LIB.dylib" \
+ "$work/device/WardenFfi.framework" "12.0"
+ # Simulator: fuse arm64 + x86_64 so it links on both Apple-Silicon and Intel hosts.
+ lipo -create \
+ "$WARDEN_DIR/target/aarch64-apple-ios-sim/release/$LIB.dylib" \
+ "$WARDEN_DIR/target/x86_64-apple-ios/release/$LIB.dylib" \
+ -output "$work/sim-universal.dylib"
+ make_framework "$work/sim-universal.dylib" "$work/sim/WardenFfi.framework" "12.0"
+
+ local out="$MOBILE_DIR/ios/WardenFfi.xcframework"
+ echo "==> iOS: assembling dynamic $out (ios-arm64 device + universal simulator)"
+ mkdir -p "$(dirname "$out")" # the --out base may not pre-exist (e.g. default dist/mobile)
+ rm -rf "$out"
+ xcodebuild -create-xcframework \
+ -framework "$work/device/WardenFfi.framework" \
+ -framework "$work/sim/WardenFfi.framework" \
+ -output "$out"
+ echo "==> iOS: done (dynamic framework — CocoaPods embeds it; symbols load at launch, no -force_load)."
+}
+
build_android() {
echo "==> Android: cross-compiling $LIB (4 ABIs, release, --locked) via cargo-ndk"
local jni="$MOBILE_DIR/android/app/src/main/jniLibs"
@@ -92,7 +152,8 @@ build_android() {
}
case "$WHAT" in
- ios) build_ios ;;
- android) build_android ;;
- all) build_ios; build_android ;;
+ ios) build_ios ;; # static .a + -force_load (legacy / manual wiring)
+ ios-framework) build_ios_framework ;; # dynamic framework (warden_ffi_flutter plugin)
+ android) build_android ;;
+ all) build_ios; build_android ;;
esac
diff --git a/ffi/flutter/.gitignore b/ffi/flutter/.gitignore
new file mode 100644
index 0000000..9faf236
--- /dev/null
+++ b/ffi/flutter/.gitignore
@@ -0,0 +1,6 @@
+.dart_tool/
+.packages
+pubspec.lock
+build/
+.flutter-plugins
+.flutter-plugins-dependencies
diff --git a/ffi/flutter/CHANGELOG.md b/ffi/flutter/CHANGELOG.md
new file mode 100644
index 0000000..caed398
--- /dev/null
+++ b/ffi/flutter/CHANGELOG.md
@@ -0,0 +1,9 @@
+## 0.1.0-dev.1
+
+- Initial pre-release. Flutter plugin that bundles the Warden native FFI library
+ (the Veil bridge) and re-exports the `warden_ffi` binding.
+- iOS: downloads + embeds the prebuilt dynamic `WardenFfi.xcframework` from the
+ warden GitHub release (`v0.1.0-dev.2`) via the podspec.
+- Android: downloads the prebuilt `jniLibs//libwarden_ffi.so` from the same
+ release via `build.gradle`.
+- Preview / Phase-0 PoC: the timing guarantee is not security on the test federation.
diff --git a/ffi/flutter/LICENSE b/ffi/flutter/LICENSE
new file mode 100644
index 0000000..7d8374d
--- /dev/null
+++ b/ffi/flutter/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 BytesBrains
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/ffi/flutter/README.md b/ffi/flutter/README.md
new file mode 100644
index 0000000..001414b
--- /dev/null
+++ b/ffi/flutter/README.md
@@ -0,0 +1,57 @@
+# warden_ffi_flutter
+
+Flutter plugin that **bundles the Warden native FFI library** (the Veil bridge) so a
+Flutter app gets the threshold conditional-decryption gate with **no Rust toolchain and
+no native build wiring**. It wraps and re-exports the pure
+[`warden_ffi`](https://pub.dev/packages/warden_ffi) binding; its only job is to deliver
+the matching native library per platform.
+
+> ⚠️ Preview / Phase-0 PoC. On a single-operator test federation the *timing* guarantee
+> is not security — this gate gives condition-binding only. Not audited.
+
+## Use
+
+```yaml
+dependencies:
+ warden_ffi_flutter: 0.1.0-dev.1
+```
+
+```dart
+import 'package:warden_ffi_flutter/warden_ffi_flutter.dart';
+
+// The native library is bundled by this plugin, so no path is needed on device.
+final warden = WardenFfi.load();
+
+final id = warden.conditionIdentity(conditionJson);
+final env = warden.sealGated(conditionJson, masterPubHex, network, blobHex);
+// …once the federation releases enough partials for this condition…
+final dId = warden.combine(partialsJson, id, fedJson);
+final blob = warden.openGated(env, dId); // == the original blobHex
+```
+
+## How the native library is delivered
+
+Binaries are large and platform-specific, so they are **not** shipped inside this package.
+They're built once per version by warden CI and published as GitHub Release assets; this
+plugin downloads the matching set at build time:
+
+- **iOS** — the [podspec](ios/warden_ffi_flutter.podspec)'s `prepare_command` downloads the
+ prebuilt **dynamic** `WardenFfi.xcframework` and CocoaPods embeds + signs it. dyld loads
+ it at app launch, so `WardenFfi.load()` resolves the symbols via
+ `DynamicLibrary.process()` — no `-force_load`, no Xcode wiring.
+- **Android** — [`build.gradle`](android/build.gradle) downloads `jniLibs//libwarden_ffi.so`
+ and adds it as a `jniLibs` source set; Gradle bundles it into the APK and
+ `WardenFfi.load()` resolves it via `DynamicLibrary.open('libwarden_ffi.so')`.
+
+The warden release tag the binaries come from is pinned in both files (`warden_tag` /
+`WARDEN_NATIVE_TAG`) and kept in lockstep with this package's version.
+
+## Desktop / tests
+
+For host (desktop/CI) use, depend on the pure `warden_ffi` package directly and pass a
+dylib path to `WardenFfi.load(path: …)` — see its
+[README](https://pub.dev/packages/warden_ffi). This plugin targets iOS + Android.
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/ffi/flutter/analysis_options.yaml b/ffi/flutter/analysis_options.yaml
new file mode 100644
index 0000000..f9b3034
--- /dev/null
+++ b/ffi/flutter/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:flutter_lints/flutter.yaml
diff --git a/ffi/flutter/android/build.gradle b/ffi/flutter/android/build.gradle
new file mode 100644
index 0000000..d0e6fa8
--- /dev/null
+++ b/ffi/flutter/android/build.gradle
@@ -0,0 +1,76 @@
+// warden_ffi_flutter — Android native delivery for the Warden Veil bridge.
+//
+// The native .so files are NOT vendored in the published package. This downloads the
+// prebuilt jniLibs from the matching warden GitHub release into the build dir and adds
+// them as a jniLibs source set, so Gradle bundles libwarden_ffi.so per ABI into the APK.
+// DynamicLibrary.open('libwarden_ffi.so') then resolves it at runtime.
+
+group 'com.bytesbrains.warden_ffi_flutter'
+version '1.0'
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.1.0'
+ }
+}
+
+rootProject.allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+apply plugin: 'com.android.library'
+
+// The warden release whose binaries this version pulls. Keep in lockstep with the tag
+// the ios/warden_ffi_flutter.podspec downloads (warden_tag).
+def WARDEN_NATIVE_TAG = 'v0.1.0-dev.2'
+def wardenJni = new File(buildDir, "warden_native")
+
+// Download + unpack the prebuilt jniLibs once (the zip contains jniLibs//...).
+task downloadWardenJni {
+ outputs.dir wardenJni
+ doLast {
+ def marker = new File(wardenJni, "jniLibs/arm64-v8a/libwarden_ffi.so")
+ if (!marker.exists()) {
+ wardenJni.mkdirs()
+ def zip = new File(buildDir, "warden-ffi-android-jniLibs.zip")
+ def url = "https://github.com/bytesbrains/warden/releases/download/${WARDEN_NATIVE_TAG}/warden-ffi-android-jniLibs.zip"
+ logger.lifecycle("warden_ffi_flutter: downloading ${url}")
+ new URL(url).withInputStream { i -> zip.withOutputStream { o -> o << i } }
+ copy {
+ from zipTree(zip)
+ into wardenJni
+ }
+ zip.delete()
+ }
+ }
+}
+
+android {
+ namespace 'com.bytesbrains.warden_ffi_flutter'
+ compileSdk 34
+
+ defaultConfig {
+ minSdk 21
+ }
+
+ // The zip unpacks to /jniLibs//libwarden_ffi.so.
+ sourceSets {
+ main {
+ jniLibs.srcDirs += ["${wardenJni}/jniLibs"]
+ }
+ }
+}
+
+// Ensure the libs are present before the library is assembled.
+tasks.configureEach { task ->
+ if (task.name == 'preBuild') {
+ task.dependsOn downloadWardenJni
+ }
+}
diff --git a/ffi/flutter/ios/warden_ffi_flutter.podspec b/ffi/flutter/ios/warden_ffi_flutter.podspec
new file mode 100644
index 0000000..9665de6
--- /dev/null
+++ b/ffi/flutter/ios/warden_ffi_flutter.podspec
@@ -0,0 +1,38 @@
+#
+# warden_ffi_flutter — iOS native delivery for the Warden Veil bridge.
+#
+# The native library is NOT vendored in the published package (binaries are large and
+# platform-specific). Instead `prepare_command` downloads the prebuilt **dynamic**
+# WardenFfi.xcframework from the matching warden GitHub release at `pod install` time,
+# and CocoaPods embeds + signs it. Because it's a dynamic framework, dyld loads it at
+# app launch and the warden_* C symbols resolve via DynamicLibrary.process() with no
+# -force_load wiring on the consumer.
+#
+Pod::Spec.new do |s|
+ s.name = 'warden_ffi_flutter'
+ s.version = '0.1.0-dev.1'
+ s.summary = 'Bundles the Warden native FFI library (Veil bridge) for Flutter iOS.'
+ s.homepage = 'https://github.com/bytesbrains/warden'
+ s.license = { :type => 'MIT', :file => '../LICENSE' }
+ s.author = { 'bytesbrains' => 'contact@bytesbrains.com' }
+ s.source = { :path => '.' }
+ s.dependency 'Flutter'
+ s.platform = :ios, '12.0'
+ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
+
+ # The warden release whose binaries this version pulls. Keep in lockstep with the
+ # tag the android/build.gradle downloads (WARDEN_NATIVE_TAG).
+ warden_tag = 'v0.1.0-dev.2'
+ s.prepare_command = <<-CMD
+ set -euo pipefail
+ if [ ! -d "WardenFfi.xcframework" ]; then
+ url="https://github.com/bytesbrains/warden/releases/download/#{warden_tag}/WardenFfi.xcframework.zip"
+ echo "warden_ffi_flutter: downloading $url"
+ curl -fsSL "$url" -o WardenFfi.xcframework.zip
+ unzip -q -o WardenFfi.xcframework.zip
+ rm -f WardenFfi.xcframework.zip
+ fi
+ CMD
+
+ s.vendored_frameworks = 'WardenFfi.xcframework'
+end
diff --git a/ffi/flutter/lib/warden_ffi_flutter.dart b/ffi/flutter/lib/warden_ffi_flutter.dart
new file mode 100644
index 0000000..af90686
--- /dev/null
+++ b/ffi/flutter/lib/warden_ffi_flutter.dart
@@ -0,0 +1,26 @@
+/// Flutter plugin that bundles the Warden native FFI library (the Veil bridge), so a
+/// Flutter app gets the threshold conditional-decryption gate with **no Rust toolchain
+/// and no native build wiring**.
+///
+/// It re-exports the pure [`warden_ffi`](https://pub.dev/packages/warden_ffi) binding;
+/// this package's only job is to deliver the matching native library per platform:
+///
+/// - **iOS** — an embedded dynamic `WardenFfi.framework` (the podspec downloads it from
+/// the warden release and CocoaPods embeds + signs it). dyld loads it at launch, so
+/// [WardenFfi.load] resolves the symbols via `DynamicLibrary.process()` — no path,
+/// no `-force_load`.
+/// - **Android** — `jniLibs//libwarden_ffi.so` (gradle downloads it from the same
+/// release). [WardenFfi.load] resolves it via `DynamicLibrary.open('libwarden_ffi.so')`.
+///
+/// ```dart
+/// import 'package:warden_ffi_flutter/warden_ffi_flutter.dart';
+///
+/// final warden = WardenFfi.load(); // bundled native lib — no path needed on device
+/// final id = warden.conditionIdentity(conditionJson);
+/// ```
+///
+/// ⚠️ Preview: on a single-operator test federation the *timing* guarantee is not
+/// security — this gate gives condition-binding only.
+library;
+
+export 'package:warden_ffi/warden_ffi.dart';
diff --git a/ffi/flutter/pubspec.yaml b/ffi/flutter/pubspec.yaml
new file mode 100644
index 0000000..3464731
--- /dev/null
+++ b/ffi/flutter/pubspec.yaml
@@ -0,0 +1,43 @@
+name: warden_ffi_flutter
+description: >-
+ Flutter plugin that bundles the Warden native FFI library (the Veil bridge) — a
+ threshold conditional-decryption gate — so an app gets it with no Rust toolchain
+ and no native build wiring. Wraps the warden_ffi binding.
+# Pre-release: Warden is a Phase-0 PoC; the timing guarantee is unproven on a
+# production federation. Pinned EXACT, same discipline as warden_ffi.
+version: 0.1.0-dev.1
+repository: https://github.com/bytesbrains/warden
+issue_tracker: https://github.com/bytesbrains/warden/issues
+homepage: https://github.com/bytesbrains/warden
+topics:
+ - ffi
+ - cryptography
+ - threshold
+ - blockchain
+
+environment:
+ sdk: ^3.4.0
+ flutter: '>=3.10.0'
+
+dependencies:
+ flutter:
+ sdk: flutter
+ # The pure Dart binding. This plugin only adds the native library + platform wiring.
+ # A range (not an exact pin) so consumers can co-resolve warden_ffi — pub.dev rejects
+ # an exact pin on a published library. An end app that wants an exact pin still sets
+ # one in its own pubspec (it wins in resolution); the ABI is stable within 0.1.x.
+ warden_ffi: ^0.1.0-dev.1
+
+dev_dependencies:
+ flutter_lints: ^4.0.0
+
+# FFI plugin: no Dart plugin class — the native library is bundled per platform
+# (iOS: an embedded dynamic framework downloaded by the podspec; Android: jniLibs
+# downloaded by build.gradle). DynamicLibrary.process()/open() then resolves it.
+flutter:
+ plugin:
+ platforms:
+ android:
+ ffiPlugin: true
+ ios:
+ ffiPlugin: true