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_inference pub.dev package wrapping the onde Rust 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):

  1. Run flutter pub get locally — a version-solving failure tells you immediately if the Dart SDK shipped with the current Flutter is too old.
  2. Find the Flutter release that ships the required Dart SDK version (see https://docs.flutter.dev/release/archive).
  3. Update sdk/dart/.flutter-version to that version.
  4. Tighten pubspec.yaml's environment.sdk lower 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:

  1. Import only types.dart — never dart:ffi or anything platform-native.
  2. RustLib.init() sets a bool flag; new_() and every inference method throw UnimplementedError.
  3. Config/sampling free functions return real hardcoded values (matching the Rust constants) so model-selection UIs can be built and tested immediately.
  4. 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.