main
name: sdk-dart description: Dart/Flutter SDK skill for the onde_inference pub.dev package. Covers flutter_rust_bridge v2 codegen, CocoaPods podspec patterns, ExternalLibrary.open init, .pubignore, Xcode 26 compatibility, Android NDK config, and pub.dev publishing. Apply to all Dart SDK edits. allowed-tools: Read, Write, Edit, Glob, Grep
user-invocable: false
SKILL: Dart / Flutter SDK with flutter_rust_bridge v2
Captured from: building
onde_inferencepub.dev package wrapping theondeRust crate. Agent reference for future Dart SDK tasks in this repo.
What This Skill Covers
End-to-end creation of a pub.dev-ready Flutter FFI plugin package that bridges to a
Rust library using flutter_rust_bridge v2. Covers the full lifecycle:
design → Rust bridge crate → Dart library → platform build files → tests →
pub.dev dry-run → codegen → flutter run.
Hard-Won Lessons (Read First)
These are non-obvious facts that caused real failures and are not in the FRB docs.
1. flutter_rust_bridge_codegen is a Rust binary — NOT a Dart package
# Correct — install the Rust binary once:
cargo install flutter_rust_bridge_codegen
# WRONG — this package does not exist on pub.dev:
# dev_dependencies:
# flutter_rust_bridge_codegen: ^2.12.0 ← will fail `dart pub get`
Never add it to pubspec.yaml dev_dependencies.
2. flutter_rust_bridge.yaml — use module path syntax, not file paths
# FRB 2.x CORRECT syntax:
rust_input: "crate::api"
rust_root: "rust"
# WRONG (causes parse error in FRB 2.x):
# rust_input:
# - "rust/src/api.rs"
# WRONG (causes migration error in FRB 2.x):
# rust_input: "rust/src/api.rs"
3. Delete frb_generated.rs before running codegen — never pre-create it
FRB codegen uses an exclusive create for rust/src/frb_generated.rs.
If the file exists (even as a stub), codegen aborts with File exists (os error 17).
# Before first codegen run:
rm -f rust/src/frb_generated.rs
# frb_generated.dart (the Dart stub) CAN pre-exist — FRB overwrites it safely.
4. cargo-expand is required by FRB codegen — auto-installs but takes time
FRB codegen runs cargo expand internally to parse macro-expanded Rust.
If missing, it installs automatically but adds ~2 min to the first run.
Pre-install it:
cargo install cargo-expand
5. Example apps need flutter create . — a pubspec.yaml alone is not enough
A Flutter project requires platform scaffolding (ios/, android/, macos/,
Runner.xcodeproj, etc.) that only flutter create generates.
cd sdk/dart/example
flutter create . \
--project-name onde_inference_example \
--platforms=ios,macos,android,linux,windows \
--org com.ondeinference
Run this before flutter pub get or flutter run.
6. Types with #[uniffi] cannot be re-used directly in FRB
If the source Rust crate annotates types with #[uniffi::Record], #[uniffi::Enum],
etc., those attributes conflict with FRB code generation. The fix is type mirroring:
re-declare every bridged type as a plain struct/enum in the bridge crate (rust/src/api.rs)
and implement bidirectional From<> conversions.
// In rust/src/api.rs — mirror type (no #[uniffi] here)
#[derive(Debug, Clone)]
pub struct ChatMessage { pub role: ChatRole, pub content: String }
// Conversion to/from the onde crate's type
impl From<OndeChatMessage> for ChatMessage { ... }
impl From<ChatMessage> for OndeChatMessage { ... }
7. StreamSink<T> requires T: SseEncode — satisfied by codegen, not by hand
The StreamSink type (used for streaming) demands T: SseEncode. The real impl
is generated by codegen. The pre-codegen stub in frb_generated.rs must provide
a no-op impl to let cargo check pass:
// rust/src/frb_generated.rs (pre-codegen stub)
use flutter_rust_bridge::for_generated::SseCodec;
flutter_rust_bridge::frb_generated_boilerplate!(
default_stream_sink_codec = SseCodec,
default_rust_opaque = RustOpaqueMoi,
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
impl SseEncode for crate::api::StreamChunk {
fn sse_encode(self, _s: &mut flutter_rust_bridge::for_generated::SseSerializer) {
unimplemented!("codegen stub")
}
}
8. Dart lint: no leading underscore for library import prefixes
// BAD — triggers `no_leading_underscores_for_library_prefixes` lint:
import 'frb_generated.dart' as _frb;
// GOOD:
import 'frb_generated.dart' as frb;
9. Dart lint: remove library <name>; directive
// BAD — triggers `unnecessary_library_name` lint:
library onde_inference;
// GOOD:
library;
10. Exclude FRB-generated files from Dart analysis
# analysis_options.yaml
analyzer:
exclude:
- lib/src/frb_generated.dart
- lib/src/frb_generated.io.dart
- lib/src/frb_generated.web.dart
11. Flutter version is a single source of truth — never hardcode it in CI
The CI workflow reads the Flutter version from sdk/dart/.flutter-version via
subosito/flutter-action's flutter-version-file: parameter. Never
hardcode flutter-version: "x.y.z" directly in the workflow YAML.
sdk/dart/.flutter-version ← only place the Flutter version is written
# .github/workflows/release-sdk-dart.yml (both jobs)
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version-file: sdk/dart/.flutter-version # reads .flutter-version
channel: stable
cache: true
Why this matters: Each Flutter release ships a specific Dart SDK version.
Dev dependencies like freezed have minimum Dart SDK requirements that advance
faster than Flutter stable releases. When you bump a Flutter-sensitive dep,
you must also bump the Flutter version used in CI — and .flutter-version is
the one place to do that.
Rule: when bumping a dep that constrains the Dart SDK (e.g. freezed,
flutter_rust_bridge, build_runner):
- Run
flutter pub getlocally — a version-solving failure tells you immediately if the Dart SDK shipped with the current Flutter is too old. - Find the Flutter release that ships the required Dart SDK version (see https://docs.flutter.dev/release/archive).
- Update
sdk/dart/.flutter-versionto that version. - Tighten
pubspec.yaml'senvironment.sdklower bound to match — it must honestly reflect what your deps need, not what you originally wrote.
pubspec.yaml environment constraint must be accurate:
# WRONG — lying lower bound causes CI to pass locally but fail on old runners
environment:
sdk: ">=3.3.0 <4.0.0" # freezed ^3.2.x actually requires >=3.8.0
# CORRECT
environment:
sdk: ">=3.8.0 <4.0.0"
flutter: ">=3.32.0"
An inaccurate sdk: lower bound is invisible locally (you're already on a
newer SDK) but breaks any runner or user on the stated minimum.
Package Structure Reference
sdk/dart/
├── pubspec.yaml # Flutter plugin, ffiPlugin:true per platform
├── analysis_options.yaml # Exclude frb_generated files
├── flutter_rust_bridge.yaml # Codegen config (rust_input: "crate::api")
├── CHANGELOG.md
├── LICENSE
├── README.md # pub.dev landing page
│
├── rust/ # Rust bridge crate
│ ├── Cargo.toml # crate-type=[cdylib,staticlib]; publish=false
│ └── src/
│ ├── lib.rs # mod frb_generated; pub mod api; + #[frb(init)]
│ ├── api.rs # Mirror types + OndeChatEngine + free fns
│ └── frb_generated.rs # DELETE before codegen; stub only pre-codegen
│
├── lib/
│ ├── <package_name>.dart # Public barrel — exports types + engine + RustLib
│ └── src/
│ ├── types.dart # Standalone Dart types — NO native dependency
│ ├── engine.dart # OndeChatEngine wrapper + static helpers
│ ├── frb_generated.dart # CAN be stub pre-codegen; overwritten by codegen
│ └── frb_generated_stub.dart # Hand-written stub: RustLib + OndeChatEngine
│
├── android/
│ ├── build.gradle # ffiPlugin CMake integration
│ └── CMakeLists.txt # cargo build per Android ABI
├── ios/
│ └── <package>.podspec # script_phase: cargo build aarch64-apple-ios[-sim]
├── macos/
│ └── <package>.podspec # script_phase: cargo build aarch64-apple-darwin
├── linux/
│ ├── CMakeLists.txt
│ └── <package>_plugin.cc
├── windows/
│ ├── CMakeLists.txt
│ └── <package>_plugin.cpp
│
├── test/
│ └── dart_test.dart # 22+ unit tests — all pass WITHOUT native binary
└── example/
├── pubspec.yaml
└── lib/main.dart # Full Material 3 demo app
pubspec.yaml Template
name: <package_name> # e.g. onde_inference (lowercase_snake)
description: >-
One-liner for pub.dev search (80 chars max for score).
version: 0.1.0
repository: https://github.com/ondeinference/onde
homepage: https://ondeinference.com
issue_tracker: https://github.com/ondeinference/onde/issues
environment:
sdk: '>=3.8.0 <4.0.0' # must reflect the actual minimum required by deps
flutter: '>=3.32.0' # Flutter 3.32 ships Dart 3.8; update .flutter-version too
dependencies:
flutter:
sdk: flutter
flutter_rust_bridge: ^2.12.0 # Must match Rust crate version exactly
dev_dependencies:
flutter_test:
sdk: flutter
test: ^1.25.0 # For pure-Dart unit tests
flutter_lints: ^5.0.0
# NOTE: flutter_rust_bridge_codegen is a Rust binary, NOT a Dart package.
# Install: cargo install flutter_rust_bridge_codegen
flutter:
plugin:
platforms:
android:
ffiPlugin: true
ios:
ffiPlugin: true
macos:
ffiPlugin: true
linux:
ffiPlugin: true
windows:
ffiPlugin: true
flutter_rust_bridge.yaml Template
rust_root: "rust"
rust_input: "crate::api" # Module path, not file path
dart_output: "lib/src/frb_generated.dart"
dart_entrypoint_class_name: RustLib
dart_preamble: |
// ignore_for_file: type=lint, unused_import, unnecessary_import
web: false # On-device inference requires native access
Rust Bridge Crate (rust/Cargo.toml) Template
[package]
name = "<package>_dart" # e.g. onde_inference_dart
version = "0.1.0"
edition = "2021"
publish = false # Bridge crate is not published to crates.io
[lib]
crate-type = ["cdylib", "staticlib"]
# cdylib → .so / .dylib / .dll for Android / macOS / Linux / Windows
# staticlib → .a for iOS (dynamic linking not allowed)
[dependencies]
flutter_rust_bridge = "=2.12.0" # Must match Dart package version exactly
<source_crate> = { path = "../../.." } # Relative path to the wrapped crate
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
thiserror = "2"
log = "0.4"
Rust src/lib.rs Template
mod frb_generated; // Generated by flutter_rust_bridge_codegen
pub mod api;
/// Called once when the Dart RustLib.init() is invoked.
#[flutter_rust_bridge::frb(init)]
pub fn init_app() {
flutter_rust_bridge::setup_default_user_utils();
}
Type Mirroring Pattern in src/api.rs
Use this pattern for every Rust type that crosses the bridge.
use flutter_rust_bridge::frb;
use <source_crate>::inference::{
ChatMessage as SrcChatMessage,
ChatRole as SrcChatRole,
// ...
};
// 1. Declare a mirror type with no framework annotations
#[derive(Debug, Clone, PartialEq)]
pub enum ChatRole { System, User, Assistant }
// 2. Bidirectional conversions
impl From<SrcChatRole> for ChatRole { ... }
impl From<ChatRole> for SrcChatRole { ... }
// 3. Opaque engine wrapper — FRB auto-wraps in Arc<RwLock<T>>
pub struct OndeChatEngine { inner: <source_crate>::inference::ChatEngine }
impl OndeChatEngine {
#[frb(sync)] // sync constructor → no Future on Dart side
pub fn new() -> OndeChatEngine { OndeChatEngine { inner: ChatEngine::new() } }
pub async fn send_message(&self, message: String) -> Result<InferenceResult, OndeError> {
self.inner.send_message(message).await
.map(InferenceResult::from)
.map_err(OndeError::from)
}
// Streaming: use StreamSink (maps to Dart Stream<T>)
pub async fn stream_message(
&self,
message: String,
sink: StreamSink<StreamChunk>,
) -> Result<(), OndeError> {
let mut rx = self.inner.stream_message(message).await.map_err(OndeError::from)?;
while let Some(chunk) = rx.recv().await {
let done = chunk.done;
sink.add(StreamChunk::from(chunk));
if done { break; }
}
Ok(())
}
}
// Free functions with #[frb(sync)] → synchronous on Dart side
#[frb(sync)]
pub fn default_model_config() -> GgufModelConfig {
<source_crate>::inference::GgufModelConfig::platform_default().into()
}
Dart Stub Pattern (lib/src/frb_generated_stub.dart)
The stub lets the package compile and all pure-Dart unit tests pass before the native binary exists. Key rules:
- Import only
types.dart— neverdart:ffior anything platform-native. RustLib.init()sets a bool flag;new_()and every inference method throwUnimplementedError.- Config/sampling free functions return real hardcoded values (matching the Rust constants) so model-selection UIs can be built and tested immediately.
- Make every method signature match exactly what FRB will generate (same names, same parameter types, same return types) — this makes the post-codegen transition a drop-in replacement.
// frb_generated.dart (stub redirect)
export 'frb_generated_stub.dart';
// frb_generated_stub.dart
import 'types.dart';
class RustLib {
static bool _initialized = false;
static bool get isInitialized => _initialized;
static Future<void> init() async { _initialized = true; }
}
class OndeChatEngine {
static Future<OndeChatEngine> new_() async =>
throw UnimplementedError('Run flutter_rust_bridge_codegen generate');
Future<double> loadDefaultModel({ String? systemPrompt, SamplingConfig? sampling }) =>
throw UnimplementedError(_msg('loadDefaultModel'));
Stream<StreamChunk> streamMessage({ required String message }) =>
Stream.error(UnimplementedError(_msg('streamMessage')), StackTrace.current);
static String _msg(String m) => 'OndeChatEngine.$m: run codegen first';
}
// Free functions — return real data so UI can be built/tested without native
GgufModelConfig qwen251_5bConfig() => const GgufModelConfig(
modelId: 'bartowski/Qwen2.5-1.5B-Instruct-GGUF',
files: ['Qwen2.5-1.5B-Instruct-Q4_K_M.gguf'],
displayName: 'Qwen 2.5 1.5B',
approxMemory: '~941 MB',
);
Dart Public API Pattern (lib/src/engine.dart)
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'
show ExternalLibrary;
import 'frb_generated.dart/frb_generated.dart' as frb;
import 'frb_generated.dart/api.dart' as api;
import 'types.dart';
export 'frb_generated.dart/frb_generated.dart' show RustLib;
export 'frb_generated.dart/api.dart'
show OndeChatEngine, OndeError, OndeError_NoModelLoaded, /* ... */;
/// Extension on the FRB-generated OndeChatEngine opaque type.
extension OndeChatEngineX on api.OndeChatEngine {
Future<double> loadDefaultModel({
String? systemPrompt,
SamplingConfig? sampling,
}) =>
loadGgufModel(
config: api.defaultModelConfig(),
systemPrompt: systemPrompt,
sampling: sampling,
);
Future<int> clearHistoryCount() async => (await clearHistory()).toInt();
}
abstract final class OndeInference {
/// Initialises the Rust shared library.
///
/// On macOS and iOS the Rust static library is force-loaded into the
/// onde_inference CocoaPods framework via OTHER_LDFLAGS in the podspec.
/// We must open that framework explicitly — DynamicLibrary.process()
/// (RTLD_DEFAULT) cannot resolve symbols inside a separately-loaded
/// framework.
static Future<void> init() async {
ExternalLibrary? lib;
if (Platform.isMacOS || Platform.isIOS) {
lib = ExternalLibrary.open(
'onde_inference.framework/onde_inference',
);
}
await frb.RustLib.init(externalLibrary: lib);
}
static GgufModelConfig defaultModelConfig() => api.defaultModelConfig();
static SamplingConfig deterministicSamplingConfig() => api.deterministicSamplingConfig();
}
Why ExternalLibrary.open(...) and not .process()
The -force_load linker flag embeds Rust symbols into the pod framework
(onde_inference.framework/onde_inference), NOT into the main executable.
DynamicLibrary.process() maps to RTLD_DEFAULT, which only searches the
main executable and already-opened images in certain configurations. On
macOS/iOS with CocoaPods use_frameworks!, the pod framework is a separate
Mach-O image — you must dlopen it by name.
FRB 2.12's ExternalLibrary.open(path) calls DynamicLibrary.open(path)
under the hood. On non-Apple platforms (Android, Linux, Windows) the default
ExternalLibraryLoaderConfig stem-based lookup works, so lib stays null
and RustLib.init() handles it automatically.
Do NOT use ExternalLibrary.process(iKnowHowToUseIt: true) — it will
produce Failed to lookup symbol 'frb_get_rust_content_hash': dlsym(RTLD_DEFAULT, ...).
Platform Build Files
Dummy Swift Plugin Classes (required)
Both macos/Classes/OndeInferencePlugin.swift and ios/Classes/OndeInferencePlugin.swift
must exist — they are no-op FlutterPlugin stubs. Without them, CocoaPods
has no source files to compile and won't produce a real .framework target.
The Rust static library is force-loaded INTO this framework.
// macos/Classes/OndeInferencePlugin.swift
import FlutterMacOS
public class OndeInferencePlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
// No-op — all FFI symbols come from the Rust static library.
}
}
// ios/Classes/OndeInferencePlugin.swift
import Flutter // NOT FlutterMacOS
public class OndeInferencePlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {}
}
macOS podspec — full working version
Pod::Spec.new do |s|
s.name = 'onde_inference'
s.version = '0.1.0'
s.summary = 'On-device LLM inference SDK for Flutter (macOS).'
s.description = 'Runs Qwen 2.5 models locally on macOS with Metal acceleration.'
s.homepage = 'https://ondeinference.com'
s.license = { :type => 'MIT', :file => '../LICENSE' }
s.author = { 'Onde Inference' => 'hello@ondeinference.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'FlutterMacOS'
s.platform = :osx, '10.15'
s.swift_version = '5.0'
s.script_phases = [
{
:name => 'Build Rust bridge (onde_inference_dart)',
:script => <<~SHELL,
set -e
RUST_DIR="${PODS_TARGET_SRCROOT}/../rust"
cd "$RUST_DIR"
ARCH=$(uname -m)
if [[ "$ARCH" == "arm64" ]]; then
TARGET="aarch64-apple-darwin"
else
TARGET="x86_64-apple-darwin"
fi
cargo build --release --target "$TARGET"
cp "target/$TARGET/release/libonde_inference_dart.a" \
"${PODS_TARGET_SRCROOT}/libonde_inference_dart.a"
SHELL
:execution_position => :before_compile,
:output_files => ["${PODS_TARGET_SRCROOT}/libonde_inference_dart.a"],
}
]
# SystemConfiguration is required by Rust hyper_util (proxy detection)
s.frameworks = 'SystemConfiguration'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'OTHER_LDFLAGS' => '-force_load ${PODS_TARGET_SRCROOT}/libonde_inference_dart.a',
}
s.preserve_paths = '../rust/**/*'
end
Key points:
- s.frameworks = 'SystemConfiguration' — required because the Rust hyper_util
crate (transitive via reqwest) references SCDynamicStoreCopyProxies,
SCNetworkReachabilityCreateWithName, etc. Without it you get
ld: symbol(s) not found for architecture arm64.
- -force_load links ALL symbols from the .a into the pod framework.
- s.source_files = 'Classes/**/*' picks up the dummy Swift plugin.
iOS podspec — differs from macOS
s.dependency 'Flutter' # NOT 'FlutterMacOS'
s.platform = :ios, '13.0' # NOT :osx
# Target selection differs:
# iphonesimulator → aarch64-apple-ios-sim
# iphoneos → aarch64-apple-ios
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'OTHER_LDFLAGS' =>
'-force_load ${PODS_TARGET_SRCROOT}/libonde_inference_dart.a',
}
Android CMakeLists.txt — key section
if(ANDROID_ABI STREQUAL "arm64-v8a")
set(CARGO_TARGET "aarch64-linux-android")
elseif(ANDROID_ABI STREQUAL "armeabi-v7a")
set(CARGO_TARGET "armv7-linux-androideabi")
elseif(ANDROID_ABI STREQUAL "x86_64")
set(CARGO_TARGET "x86_64-linux-android")
elseif(ANDROID_ABI STREQUAL "x86")
set(CARGO_TARGET "i686-linux-android")
endif()
add_custom_command(OUTPUT "${LIB_OUTPUT}"
COMMAND cargo build --release --target ${CARGO_TARGET}
--manifest-path "${RUST_DIR}/Cargo.toml"
WORKING_DIRECTORY ${RUST_DIR})
add_library(onde_inference_dart SHARED IMPORTED GLOBAL)
set_target_properties(onde_inference_dart PROPERTIES IMPORTED_LOCATION ${LIB_OUTPUT})
Android uses CMake to invoke cargo build for the right NDK triple, producing
a .so shared library (unlike Apple which uses .a staticlibs).
Android NDK Linker Config (rust/.cargo/config.toml)
[target.aarch64-linux-android]
linker = "aarch64-linux-android24-clang"
[target.armv7-linux-androideabi]
linker = "armv7a-linux-androideabi24-clang"
[target.x86_64-linux-android]
linker = "x86_64-linux-android24-clang"
[target.i686-linux-android]
linker = "i686-linux-android24-clang"
These short names require the NDK toolchain bin/ to be on PATH.
Flutter's Gradle + CMake integration typically handles this automatically
during flutter build apk / flutter run -d <android>.
Testing Strategy
All pure-Dart unit tests run without building the native binary.
Tests that PASS pre-codegen (using the stub):
✓ All type constructors, equality, hashCode, copyWith
✓ Enum toString and isReady helpers
✓ SamplingConfig presets (default, deterministic, mobile)
✓ GgufModelConfig factory functions (real hardcoded values in stub)
✓ RustLib.init() is idempotent
✓ OndeInference.* static helpers
✓ OndeChatEngine.create() throws UnimplementedError pre-codegen
Tests that require codegen + native binary:
✗ Any actual inference call
✗ loadDefaultModel / sendMessage / streamMessage
Run tests:
bash
cd sdk/dart && dart test test/dart_test.dart
Pub.dev Readiness Checklist
Run this before declaring the SDK ready:
# 1. Dart analysis — must be zero issues
dart analyze lib/
# 2. All tests pass
dart test test/dart_test.dart
# 3. Dry-run publish — must show compressed size < 100 MB and minimal warnings
dart pub publish --dry-run
Common pub.dev score deductions to avoid:
- Missing repository: in pubspec → loses points
- No example/ directory → loses points
- description: under 60 chars or over 180 chars → loses points
- Library exports not documented → loses points
- library <name>; directive → triggers lint
Pub.dev Upload Size (100 MB limit)
dart pub publish bundles everything not excluded by .pubignore (or
.gitignore if .pubignore doesn't exist). The Rust target/ dir alone
can be 7+ GB. A .pubignore file is mandatory.
Files that MUST be included (consumers build from source):
- rust/src/, rust/Cargo.toml, rust/Cargo.lock
- ios/, macos/, android/, linux/, windows/ (build scripts, podspecs, CMakeLists)
- ios/Classes/, macos/Classes/ (dummy plugin Swift files)
- lib/ (all Dart code)
Files that MUST be excluded (.pubignore):
- rust/target/ — multi-GB build artifacts
- **/*.a, **/*.so, **/*.dylib, **/*.dll — precompiled binaries
- .dart_tool/, pubspec.lock
- example/build/, example/.dart_tool/
- logs/, .idea/, .vscode/, .DS_Store
After adding .pubignore, verify: dart pub publish --dry-run should report
~280 KB compressed size, not hundreds of MB.
Codegen Workflow (Full, Correct Order)
# ── Prerequisites (one-time) ──────────────────────────────────────────────
rustup target add aarch64-apple-ios aarch64-apple-ios-sim # iOS
rustup target add aarch64-apple-darwin x86_64-apple-darwin # macOS
cargo install cargo-expand # Required by FRB codegen parser
cargo install flutter_rust_bridge_codegen # The codegen tool itself
# ── Per-package setup ─────────────────────────────────────────────────────
cd sdk/dart
# IMPORTANT: delete the pre-codegen Rust stub first
rm -f rust/src/frb_generated.rs
# Run codegen — generates:
# rust/src/frb_generated.rs (Rust glue)
# lib/src/frb_generated.dart (Dart FFI bindings, replaces stub)
flutter_rust_bridge_codegen generate
# Verify Rust still compiles cleanly
cd rust && cargo check
# Verify Dart analysis is still clean
cd .. && dart analyze lib/
flutter run on iOS (Full Steps)
# 1. Scaffold the example Flutter project (one-time)
cd sdk/dart/example
flutter create . \
--project-name <example_name> \
--platforms=ios,macos \
--org com.<your_org>
# 2. Get dependencies
flutter pub get
# 3. Run codegen (from the SDK package root, not example root)
cd ..
flutter_rust_bridge_codegen generate
# 4. Run the app (from example dir)
cd example
flutter run # picks up connected device/simulator automatically
flutter run -d iPhone # target a specific device
What happens during the first flutter run on iOS:
1. Flutter invokes pod install → reads ios/onde_inference.podspec
2. The podspec script phase runs cargo build --release --target aarch64-apple-ios-sim
(or -device) inside sdk/dart/rust/ — this takes 5–15 min first time
3. The resulting libonde_inference_dart.a is linked into the Runner app
4. Flutter's Dart VM starts, calls RustLib.init(), loads the native library
5. The model downloads from HuggingFace Hub on first inference (~941 MB or ~1.93 GB)
HuggingFace token for private/gated models: ```bash
Bake token at build time (required for iOS — no filesystem access):
export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx flutter run ```
Version Pinning Rule
The flutter_rust_bridge Dart package and flutter_rust_bridge Rust crate
must be the same version. Use exact pins:
# rust/Cargo.toml
flutter_rust_bridge = "=2.12.0"
# pubspec.yaml
dependencies:
flutter_rust_bridge: ^2.12.0 # ^ is OK on Dart side; codegen auto-upgrades
If they drift, generated code won't compile. Always check both when upgrading.
Common Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
rust_input: invalid type: sequence |
YAML list syntax in FRB 2.x | Change to rust_input: "crate::api" |
Please migrate configuration rust_input |
File path in FRB 2.x | Change to rust_input: "crate::api" |
File exists (os error 17) |
frb_generated.rs pre-exists |
rm rust/src/frb_generated.rs |
could not find package flutter_rust_bridge_codegen |
Added codegen as Dart dep | Remove it; install via cargo install |
no such command: expand |
cargo-expand not installed |
cargo install cargo-expand |
unexpected cfg condition name: frb_expand |
Rust check-cfg lint | Harmless FRB macro warning; suppress with #[allow(unexpected_cfgs)] or ignore |
no_leading_underscores_for_library_prefixes |
Import alias as _frb |
Rename to as frb |
unnecessary_library_name |
library <name>; in barrel |
Change to bare library; |
OndeChatEngine.create() throws UnimplementedError |
Codegen not run yet | Run flutter_rust_bridge_codegen generate |
flutter run fails, missing Runner.xcodeproj |
flutter create not run |
Run flutter create . --platforms=ios ... in example dir |
dlopen(onde_inference_dart.framework/...): no such file |
FRB default loader looks for wrong framework | Use ExternalLibrary.open('onde_inference.framework/onde_inference') in init() — see Dart Public API Pattern above |
Failed to lookup symbol 'frb_get_rust_content_hash': dlsym(RTLD_DEFAULT, ...) |
Used DynamicLibrary.process() but symbols are in pod framework, not main executable |
Switch from ExternalLibrary.process() to ExternalLibrary.open('onde_inference.framework/onde_inference') |
ld: symbol(s) not found ... _kSCNetworkReachability... |
Missing SystemConfiguration framework in podspec |
Add s.frameworks = 'SystemConfiguration' to macOS podspec |
ld: error: unknown argument '-Xlinker' / -dynamiclib / -filelist |
Android NDK's ld (LLD) is on PATH — Xcode picks it up instead of Apple's linker |
Remove export LD=$TOOLCHAIN/bin/ld and other NDK tool exports from ~/.zshrc (see Xcode 26 section) |
clang: error: invalid linker name in argument '-fuse-ld=classic' |
ALTERNATE_LINKER = classic set but clang doesn't support it |
Remove ALTERNATE_LINKER; was only needed to work around the NDK-on-PATH issue — once that's fixed, Apple's ld-prime works fine |
EntityTooLarge on dart pub publish |
Package > 100 MB (rust/target/ included) | Create .pubignore excluding rust/target/, **/*.a, **/*.so, etc. |
Because <pkg> requires SDK version >=X.Y.0 ... version solving failed |
pubspec.yaml environment.sdk lower bound is too old; CI Flutter version too old |
1. Update sdk/dart/.flutter-version to a Flutter that ships the required Dart SDK. 2. Tighten environment.sdk in pubspec.yaml to the real minimum. See Hard-Won Lesson 11. |
You must have a LICENSE file in the root directory on dart pub publish |
pub.dev requires a file named exactly LICENSE; LICENSE-MIT / LICENSE-APACHE alone do not satisfy it |
Create sdk/dart/LICENSE with an "either of … at your option" preamble, then append both full license texts. A bare concatenation without a preamble is legally ambiguous — it implies users must comply with both, defeating the purpose of dual licensing. Run from sdk/dart/: cat > LICENSE << 'EOF' then the preamble (see legal-and-trademarks SKILL.md for exact wording), then { cat LICENSE-MIT; printf '\n\n---\n\n'; cat LICENSE-APACHE; } >> LICENSE. Keep LICENSE-MIT and LICENSE-APACHE alongside it. |
Xcode 26 Compatibility
The NDK-on-PATH Trap
If your ~/.zshrc (or similar) has global Android NDK toolchain exports:
# DANGEROUS — breaks ALL non-Android builds (Xcode, Rust for Apple, etc.)
export TOOLCHAIN=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64
export LD=$TOOLCHAIN/bin/ld # ← THIS HIJACKS XCODE'S LINKER
export AR=$TOOLCHAIN/bin/llvm-ar
export CXX=$TOOLCHAIN/bin/clang++
export RANLIB=$TOOLCHAIN/bin/llvm-ranlib
export STRIP=$TOOLCHAIN/bin/llvm-strip
Xcode reads $LD and invokes the Android NDK's LLD instead of Apple's linker.
The errors look like Xcode 26 ld-prime incompatibility (unknown argument '-Xlinker')
but the root cause is the wrong linker binary.
Fix: Comment out all NDK tool exports. Keep only the env vars:
export NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865"
export ANDROID_NDK="$NDK_HOME"
export ANDROID_NDK_HOME="$NDK_HOME"
# DO NOT export LD, AR, CXX, RANLIB, STRIP globally.
# Cargo uses rust/.cargo/config.toml for Android linker selection.
Does Xcode 26's ld-prime work with Flutter?
Yes — once the NDK tool exports are removed, Apple's ld-prime handles all
CocoaPods/Flutter linker flags correctly. No ALTERNATE_LINKER = classic or
-ld_classic workaround is needed for macOS builds. The -ld_classic
deprecation warning from iOS Podfiles is harmless.
iOS 26 Debug Mode Crash
Flutter debug builds crash on iOS 26 beta with
NOTIFY_DEBUGGER_ABOUT_RX_PAGES was not intercepted as expected
(flutter#170416).
This is a Dart VM JIT issue — not fixable in the SDK.
| Mode | iOS 26 Physical | iOS 26 Simulator | iOS 18.x |
|---|---|---|---|
--debug |
❌ Dart VM crash | ❌ Dart VM crash | ✅ Works |
--profile |
✅ Works | ❌ Not supported on sim | ✅ Works |
--release |
⚠️ Use flutter build ipa |
❌ Not supported on sim | ✅ Works |
Workaround: Use an iOS 18.x simulator runtime (download via Xcode Settings → Platforms).
File Interdependency Map
pubspec.yaml
└── declares: flutter_rust_bridge ^2.12.0
flutter_rust_bridge.yaml
├── rust_root: "rust/" → rust/Cargo.toml
├── rust_input: "crate::api" → rust/src/api.rs (source of truth)
└── dart_output: "lib/src/frb_generated.dart" (codegen writes here)
rust/Cargo.toml
├── crate-type = ["cdylib", "staticlib"]
├── flutter_rust_bridge = "=2.12.0" (exact pin!)
└── onde = { path = "../../../" } (the main onde crate)
rust/.cargo/config.toml
└── Android NDK linker overrides (aarch64, armv7, x86_64, i686)
rust/src/api.rs
├── imports onde::{ChatEngine, ...} (path dep → ../../..)
├── declares mirror types (ChatRole, ChatMessage, ...)
├── declares OndeChatEngine (wraps ChatEngine)
└── declares free functions (default_model_config, ...)
lib/src/frb_generated.dart/ ← DIRECTORY (not a file!)
├── api.dart ← generated API bindings
├── api.freezed.dart ← freezed union types (OndeError)
├── frb_generated.dart ← RustLib class, ExternalLibraryLoaderConfig
└── frb_generated.io.dart ← RustLibWire, platform FFI glue
lib/src/types.dart ← hand-written, FRB-independent
└── ChatRole, ChatMessage, SamplingConfig, GgufModelConfig,
InferenceResult, StreamChunk, EngineStatus, EngineInfo, OndeException
lib/src/engine.dart ← hand-written, imports frb_generated.dart/ + types.dart
├── extension OndeChatEngineX (loadDefaultModel, clearHistoryCount)
└── abstract class OndeInference
├── init() → ExternalLibrary.open('onde_inference.framework/...') on Apple
├── defaultModelConfig(), qwen2515bConfig(), qwen253bConfig(), ...
└── defaultSamplingConfig(), deterministicSamplingConfig(), mobileSamplingConfig()
lib/onde_inference.dart (barrel)
├── export 'src/types.dart'
└── export 'src/engine.dart'
macos/
├── Classes/OndeInferencePlugin.swift ← dummy (required for CocoaPods framework)
└── onde_inference.podspec ← script_phase + -force_load + SystemConfiguration
ios/
├── Classes/OndeInferencePlugin.swift ← dummy (required for CocoaPods framework)
└── onde_inference.podspec ← script_phase + -force_load
android/
├── CMakeLists.txt ← invokes cargo build per ABI
└── build.gradle ← externalNativeBuild → cmake
.pubignore ← excludes rust/target/, *.a, *.so, etc.
.gitignore ← excludes same + build artifacts
Last updated: July 2025 — Xcode 26 compatibility, ExternalLibrary.open pattern, SystemConfiguration framework, .pubignore for pub.dev, NDK PATH pitfall.