1 ---
2 name: sdk-dart
3 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.
4 allowed-tools: Read, Write, Edit, Glob, Grep
5 user-invocable: false
6 ---
7
8 # SKILL: Dart / Flutter SDK with flutter_rust_bridge v2
9
10 > Captured from: building `onde_inference` pub.dev package wrapping the `onde` Rust crate.
11 > Agent reference for future Dart SDK tasks in this repo.
12
13 ---
14
15 ## What This Skill Covers
16
17 End-to-end creation of a pub.dev-ready Flutter FFI plugin package that bridges to a
18 Rust library using `flutter_rust_bridge` v2. Covers the full lifecycle:
19 design → Rust bridge crate → Dart library → platform build files → tests →
20 pub.dev dry-run → codegen → `flutter run`.
21
22 ---
23
24 ## Hard-Won Lessons (Read First)
25
26 These are non-obvious facts that caused real failures and are not in the FRB docs.
27
28 ### 1. `flutter_rust_bridge_codegen` is a Rust binary — NOT a Dart package
29
30 ```
31 # Correct — install the Rust binary once:
32 cargo install flutter_rust_bridge_codegen
33
34 # WRONG — this package does not exist on pub.dev:
35 # dev_dependencies:
36 # flutter_rust_bridge_codegen: ^2.12.0 ← will fail `dart pub get`
37 ```
38
39 Never add it to `pubspec.yaml` `dev_dependencies`.
40
41 ### 2. `flutter_rust_bridge.yaml` — use module path syntax, not file paths
42
43 ```yaml
44 # FRB 2.x CORRECT syntax:
45 rust_input: "crate::api"
46 rust_root: "rust"
47
48 # WRONG (causes parse error in FRB 2.x):
49 # rust_input:
50 # - "rust/src/api.rs"
51
52 # WRONG (causes migration error in FRB 2.x):
53 # rust_input: "rust/src/api.rs"
54 ```
55
56 ### 3. Delete `frb_generated.rs` before running codegen — never pre-create it
57
58 FRB codegen uses an **exclusive create** for `rust/src/frb_generated.rs`.
59 If the file exists (even as a stub), codegen aborts with `File exists (os error 17)`.
60
61 ```bash
62 # Before first codegen run:
63 rm -f rust/src/frb_generated.rs
64
65 # frb_generated.dart (the Dart stub) CAN pre-exist — FRB overwrites it safely.
66 ```
67
68 ### 4. `cargo-expand` is required by FRB codegen — auto-installs but takes time
69
70 FRB codegen runs `cargo expand` internally to parse macro-expanded Rust.
71 If missing, it installs automatically but adds ~2 min to the first run.
72 Pre-install it:
73
74 ```bash
75 cargo install cargo-expand
76 ```
77
78 ### 5. Example apps need `flutter create .` — a `pubspec.yaml` alone is not enough
79
80 A Flutter project requires platform scaffolding (`ios/`, `android/`, `macos/`,
81 `Runner.xcodeproj`, etc.) that only `flutter create` generates.
82
83 ```bash
84 cd sdk/dart/example
85 flutter create . \
86 --project-name onde_inference_example \
87 --platforms=ios,macos,android,linux,windows \
88 --org com.ondeinference
89 ```
90
91 Run this **before** `flutter pub get` or `flutter run`.
92
93 ### 6. Types with `#[uniffi]` cannot be re-used directly in FRB
94
95 If the source Rust crate annotates types with `#[uniffi::Record]`, `#[uniffi::Enum]`,
96 etc., those attributes conflict with FRB code generation. The fix is **type mirroring**:
97 re-declare every bridged type as a plain struct/enum in the bridge crate (`rust/src/api.rs`)
98 and implement bidirectional `From<>` conversions.
99
100 ```rust
101 // In rust/src/api.rs — mirror type (no #[uniffi] here)
102 #[derive(Debug, Clone)]
103 pub struct ChatMessage { pub role: ChatRole, pub content: String }
104
105 // Conversion to/from the onde crate's type
106 impl From<OndeChatMessage> for ChatMessage { ... }
107 impl From<ChatMessage> for OndeChatMessage { ... }
108 ```
109
110 ### 7. `StreamSink<T>` requires `T: SseEncode` — satisfied by codegen, not by hand
111
112 The `StreamSink` type (used for streaming) demands `T: SseEncode`. The real impl
113 is generated by codegen. The pre-codegen stub in `frb_generated.rs` must provide
114 a no-op impl to let `cargo check` pass:
115
116 ```rust
117 // rust/src/frb_generated.rs (pre-codegen stub)
118 use flutter_rust_bridge::for_generated::SseCodec;
119 flutter_rust_bridge::frb_generated_boilerplate!(
120 default_stream_sink_codec = SseCodec,
121 default_rust_opaque = RustOpaqueMoi,
122 default_rust_auto_opaque = RustAutoOpaqueMoi,
123 );
124 impl SseEncode for crate::api::StreamChunk {
125 fn sse_encode(self, _s: &mut flutter_rust_bridge::for_generated::SseSerializer) {
126 unimplemented!("codegen stub")
127 }
128 }
129 ```
130
131 ### 8. Dart lint: no leading underscore for library import prefixes
132
133 ```dart
134 // BAD — triggers `no_leading_underscores_for_library_prefixes` lint:
135 import 'frb_generated.dart' as _frb;
136
137 // GOOD:
138 import 'frb_generated.dart' as frb;
139 ```
140
141 ### 9. Dart lint: remove `library <name>;` directive
142
143 ```dart
144 // BAD — triggers `unnecessary_library_name` lint:
145 library onde_inference;
146
147 // GOOD:
148 library;
149 ```
150
151 ### 10. Exclude FRB-generated files from Dart analysis
152
153 ```yaml
154 # analysis_options.yaml
155 analyzer:
156 exclude:
157 - lib/src/frb_generated.dart
158 - lib/src/frb_generated.io.dart
159 - lib/src/frb_generated.web.dart
160 ```
161
162 ---
163
164 ### 11. Flutter version is a single source of truth — never hardcode it in CI
165
166 The CI workflow reads the Flutter version from `sdk/dart/.flutter-version` via
167 `subosito/flutter-action`'s `flutter-version-file:` parameter. **Never
168 hardcode `flutter-version: "x.y.z"` directly in the workflow YAML.**
169
170 ```
171 sdk/dart/.flutter-version ← only place the Flutter version is written
172 ```
173
174 ```yaml
175 # .github/workflows/release-sdk-dart.yml (both jobs)
176 - name: Set up Flutter
177 uses: subosito/flutter-action@v2
178 with:
179 flutter-version-file: sdk/dart/.flutter-version # reads .flutter-version
180 channel: stable
181 cache: true
182 ```
183
184 **Why this matters:** Each Flutter release ships a specific Dart SDK version.
185 Dev dependencies like `freezed` have minimum Dart SDK requirements that advance
186 faster than Flutter stable releases. When you bump a Flutter-sensitive dep,
187 you must also bump the Flutter version used in CI — and `.flutter-version` is
188 the one place to do that.
189
190 **Rule: when bumping a dep that constrains the Dart SDK** (e.g. `freezed`,
191 `flutter_rust_bridge`, `build_runner`):
192
193 1. Run `flutter pub get` locally — a version-solving failure tells you
194 immediately if the Dart SDK shipped with the current Flutter is too old.
195 2. Find the Flutter release that ships the required Dart SDK version
196 (see https://docs.flutter.dev/release/archive).
197 3. Update `sdk/dart/.flutter-version` to that version.
198 4. Tighten `pubspec.yaml`'s `environment.sdk` lower bound to match — it must
199 honestly reflect what your deps need, not what you originally wrote.
200
201 **`pubspec.yaml` environment constraint must be accurate:**
202
203 ```yaml
204 # WRONG — lying lower bound causes CI to pass locally but fail on old runners
205 environment:
206 sdk: ">=3.3.0 <4.0.0" # freezed ^3.2.x actually requires >=3.8.0
207
208 # CORRECT
209 environment:
210 sdk: ">=3.8.0 <4.0.0"
211 flutter: ">=3.32.0"
212 ```
213
214 An inaccurate `sdk:` lower bound is invisible locally (you're already on a
215 newer SDK) but breaks any runner or user on the stated minimum.
216
217 ---
218
219 ## Package Structure Reference
220
221 ```
222 sdk/dart/
223 ├── pubspec.yaml # Flutter plugin, ffiPlugin:true per platform
224 ├── analysis_options.yaml # Exclude frb_generated files
225 ├── flutter_rust_bridge.yaml # Codegen config (rust_input: "crate::api")
226 ├── CHANGELOG.md
227 ├── LICENSE
228 ├── README.md # pub.dev landing page
229 │
230 ├── rust/ # Rust bridge crate
231 │ ├── Cargo.toml # crate-type=[cdylib,staticlib]; publish=false
232 │ └── src/
233 │ ├── lib.rs # mod frb_generated; pub mod api; + #[frb(init)]
234 │ ├── api.rs # Mirror types + OndeChatEngine + free fns
235 │ └── frb_generated.rs # DELETE before codegen; stub only pre-codegen
236 │
237 ├── lib/
238 │ ├── <package_name>.dart # Public barrel — exports types + engine + RustLib
239 │ └── src/
240 │ ├── types.dart # Standalone Dart types — NO native dependency
241 │ ├── engine.dart # OndeChatEngine wrapper + static helpers
242 │ ├── frb_generated.dart # CAN be stub pre-codegen; overwritten by codegen
243 │ └── frb_generated_stub.dart # Hand-written stub: RustLib + OndeChatEngine
244 │
245 ├── android/
246 │ ├── build.gradle # ffiPlugin CMake integration
247 │ └── CMakeLists.txt # cargo build per Android ABI
248 ├── ios/
249 │ └── <package>.podspec # script_phase: cargo build aarch64-apple-ios[-sim]
250 ├── macos/
251 │ └── <package>.podspec # script_phase: cargo build aarch64-apple-darwin
252 ├── linux/
253 │ ├── CMakeLists.txt
254 │ └── <package>_plugin.cc
255 ├── windows/
256 │ ├── CMakeLists.txt
257 │ └── <package>_plugin.cpp
258 │
259 ├── test/
260 │ └── dart_test.dart # 22+ unit tests — all pass WITHOUT native binary
261 └── example/
262 ├── pubspec.yaml
263 └── lib/main.dart # Full Material 3 demo app
264 ```
265
266 ---
267
268 ## `pubspec.yaml` Template
269
270 ```yaml
271 name: <package_name> # e.g. onde_inference (lowercase_snake)
272 description: >-
273 One-liner for pub.dev search (80 chars max for score).
274 version: 0.1.0
275 repository: https://github.com/ondeinference/onde
276 homepage: https://ondeinference.com
277 issue_tracker: https://github.com/ondeinference/onde/issues
278
279 environment:
280 sdk: '>=3.8.0 <4.0.0' # must reflect the actual minimum required by deps
281 flutter: '>=3.32.0' # Flutter 3.32 ships Dart 3.8; update .flutter-version too
282
283 dependencies:
284 flutter:
285 sdk: flutter
286 flutter_rust_bridge: ^2.12.0 # Must match Rust crate version exactly
287
288 dev_dependencies:
289 flutter_test:
290 sdk: flutter
291 test: ^1.25.0 # For pure-Dart unit tests
292 flutter_lints: ^5.0.0
293 # NOTE: flutter_rust_bridge_codegen is a Rust binary, NOT a Dart package.
294 # Install: cargo install flutter_rust_bridge_codegen
295
296 flutter:
297 plugin:
298 platforms:
299 android:
300 ffiPlugin: true
301 ios:
302 ffiPlugin: true
303 macos:
304 ffiPlugin: true
305 linux:
306 ffiPlugin: true
307 windows:
308 ffiPlugin: true
309 ```
310
311 ---
312
313 ## `flutter_rust_bridge.yaml` Template
314
315 ```yaml
316 rust_root: "rust"
317 rust_input: "crate::api" # Module path, not file path
318 dart_output: "lib/src/frb_generated.dart"
319 dart_entrypoint_class_name: RustLib
320 dart_preamble: |
321 // ignore_for_file: type=lint, unused_import, unnecessary_import
322 web: false # On-device inference requires native access
323 ```
324
325 ---
326
327 ## Rust Bridge Crate (`rust/Cargo.toml`) Template
328
329 ```toml
330 [package]
331 name = "<package>_dart" # e.g. onde_inference_dart
332 version = "0.1.0"
333 edition = "2021"
334 publish = false # Bridge crate is not published to crates.io
335
336 [lib]
337 crate-type = ["cdylib", "staticlib"]
338 # cdylib → .so / .dylib / .dll for Android / macOS / Linux / Windows
339 # staticlib → .a for iOS (dynamic linking not allowed)
340
341 [dependencies]
342 flutter_rust_bridge = "=2.12.0" # Must match Dart package version exactly
343 <source_crate> = { path = "../../.." } # Relative path to the wrapped crate
344 tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
345 thiserror = "2"
346 log = "0.4"
347 ```
348
349 ---
350
351 ## Rust `src/lib.rs` Template
352
353 ```rust
354 mod frb_generated; // Generated by flutter_rust_bridge_codegen
355 pub mod api;
356
357 /// Called once when the Dart RustLib.init() is invoked.
358 #[flutter_rust_bridge::frb(init)]
359 pub fn init_app() {
360 flutter_rust_bridge::setup_default_user_utils();
361 }
362 ```
363
364 ---
365
366 ## Type Mirroring Pattern in `src/api.rs`
367
368 Use this pattern for every Rust type that crosses the bridge.
369
370 ```rust
371 use flutter_rust_bridge::frb;
372 use <source_crate>::inference::{
373 ChatMessage as SrcChatMessage,
374 ChatRole as SrcChatRole,
375 // ...
376 };
377
378 // 1. Declare a mirror type with no framework annotations
379 #[derive(Debug, Clone, PartialEq)]
380 pub enum ChatRole { System, User, Assistant }
381
382 // 2. Bidirectional conversions
383 impl From<SrcChatRole> for ChatRole { ... }
384 impl From<ChatRole> for SrcChatRole { ... }
385
386 // 3. Opaque engine wrapper — FRB auto-wraps in Arc<RwLock<T>>
387 pub struct OndeChatEngine { inner: <source_crate>::inference::ChatEngine }
388
389 impl OndeChatEngine {
390 #[frb(sync)] // sync constructor → no Future on Dart side
391 pub fn new() -> OndeChatEngine { OndeChatEngine { inner: ChatEngine::new() } }
392
393 pub async fn send_message(&self, message: String) -> Result<InferenceResult, OndeError> {
394 self.inner.send_message(message).await
395 .map(InferenceResult::from)
396 .map_err(OndeError::from)
397 }
398
399 // Streaming: use StreamSink (maps to Dart Stream<T>)
400 pub async fn stream_message(
401 &self,
402 message: String,
403 sink: StreamSink<StreamChunk>,
404 ) -> Result<(), OndeError> {
405 let mut rx = self.inner.stream_message(message).await.map_err(OndeError::from)?;
406 while let Some(chunk) = rx.recv().await {
407 let done = chunk.done;
408 sink.add(StreamChunk::from(chunk));
409 if done { break; }
410 }
411 Ok(())
412 }
413 }
414
415 // Free functions with #[frb(sync)] → synchronous on Dart side
416 #[frb(sync)]
417 pub fn default_model_config() -> GgufModelConfig {
418 <source_crate>::inference::GgufModelConfig::platform_default().into()
419 }
420 ```
421
422 ---
423
424 ## Dart Stub Pattern (`lib/src/frb_generated_stub.dart`)
425
426 The stub lets the package compile and all pure-Dart unit tests pass **before**
427 the native binary exists. Key rules:
428
429 1. Import only `types.dart` — never `dart:ffi` or anything platform-native.
430 2. `RustLib.init()` sets a bool flag; `new_()` and every inference method throw
431 `UnimplementedError`.
432 3. Config/sampling free functions return **real hardcoded values** (matching the
433 Rust constants) so model-selection UIs can be built and tested immediately.
434 4. Make every method signature match exactly what FRB will generate (same names,
435 same parameter types, same return types) — this makes the post-codegen
436 transition a drop-in replacement.
437
438 ```dart
439 // frb_generated.dart (stub redirect)
440 export 'frb_generated_stub.dart';
441
442 // frb_generated_stub.dart
443 import 'types.dart';
444
445 class RustLib {
446 static bool _initialized = false;
447 static bool get isInitialized => _initialized;
448 static Future<void> init() async { _initialized = true; }
449 }
450
451 class OndeChatEngine {
452 static Future<OndeChatEngine> new_() async =>
453 throw UnimplementedError('Run flutter_rust_bridge_codegen generate');
454
455 Future<double> loadDefaultModel({ String? systemPrompt, SamplingConfig? sampling }) =>
456 throw UnimplementedError(_msg('loadDefaultModel'));
457
458 Stream<StreamChunk> streamMessage({ required String message }) =>
459 Stream.error(UnimplementedError(_msg('streamMessage')), StackTrace.current);
460
461 static String _msg(String m) => 'OndeChatEngine.$m: run codegen first';
462 }
463
464 // Free functions — return real data so UI can be built/tested without native
465 GgufModelConfig qwen251_5bConfig() => const GgufModelConfig(
466 modelId: 'bartowski/Qwen2.5-1.5B-Instruct-GGUF',
467 files: ['Qwen2.5-1.5B-Instruct-Q4_K_M.gguf'],
468 displayName: 'Qwen 2.5 1.5B',
469 approxMemory: '~941 MB',
470 );
471 ```
472
473 ---
474
475 ## Dart Public API Pattern (`lib/src/engine.dart`)
476
477 ```dart
478 import 'dart:async';
479 import 'dart:io' show Platform;
480
481 import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'
482 show ExternalLibrary;
483
484 import 'frb_generated.dart/frb_generated.dart' as frb;
485 import 'frb_generated.dart/api.dart' as api;
486 import 'types.dart';
487
488 export 'frb_generated.dart/frb_generated.dart' show RustLib;
489 export 'frb_generated.dart/api.dart'
490 show OndeChatEngine, OndeError, OndeError_NoModelLoaded, /* ... */;
491
492 /// Extension on the FRB-generated OndeChatEngine opaque type.
493 extension OndeChatEngineX on api.OndeChatEngine {
494 Future<double> loadDefaultModel({
495 String? systemPrompt,
496 SamplingConfig? sampling,
497 }) =>
498 loadGgufModel(
499 config: api.defaultModelConfig(),
500 systemPrompt: systemPrompt,
501 sampling: sampling,
502 );
503
504 Future<int> clearHistoryCount() async => (await clearHistory()).toInt();
505 }
506
507 abstract final class OndeInference {
508 /// Initialises the Rust shared library.
509 ///
510 /// On macOS and iOS the Rust static library is force-loaded into the
511 /// onde_inference CocoaPods framework via OTHER_LDFLAGS in the podspec.
512 /// We must open that framework explicitly — DynamicLibrary.process()
513 /// (RTLD_DEFAULT) cannot resolve symbols inside a separately-loaded
514 /// framework.
515 static Future<void> init() async {
516 ExternalLibrary? lib;
517 if (Platform.isMacOS || Platform.isIOS) {
518 lib = ExternalLibrary.open(
519 'onde_inference.framework/onde_inference',
520 );
521 }
522 await frb.RustLib.init(externalLibrary: lib);
523 }
524
525 static GgufModelConfig defaultModelConfig() => api.defaultModelConfig();
526 static SamplingConfig deterministicSamplingConfig() => api.deterministicSamplingConfig();
527 }
528 ```
529
530 ### Why `ExternalLibrary.open(...)` and not `.process()`
531
532 The `-force_load` linker flag embeds Rust symbols into the **pod framework**
533 (`onde_inference.framework/onde_inference`), NOT into the main executable.
534 `DynamicLibrary.process()` maps to `RTLD_DEFAULT`, which only searches the
535 main executable and already-opened images in certain configurations. On
536 macOS/iOS with CocoaPods `use_frameworks!`, the pod framework is a separate
537 Mach-O image — you must `dlopen` it by name.
538
539 FRB 2.12's `ExternalLibrary.open(path)` calls `DynamicLibrary.open(path)`
540 under the hood. On non-Apple platforms (Android, Linux, Windows) the default
541 `ExternalLibraryLoaderConfig` stem-based lookup works, so `lib` stays `null`
542 and `RustLib.init()` handles it automatically.
543
544 **Do NOT use `ExternalLibrary.process(iKnowHowToUseIt: true)`** — it will
545 produce `Failed to lookup symbol 'frb_get_rust_content_hash': dlsym(RTLD_DEFAULT, ...)`.
546
547 ---
548
549 ## Platform Build Files
550
551 ### Dummy Swift Plugin Classes (required)
552
553 Both `macos/Classes/OndeInferencePlugin.swift` and `ios/Classes/OndeInferencePlugin.swift`
554 **must exist** — they are no-op `FlutterPlugin` stubs. Without them, CocoaPods
555 has no source files to compile and won't produce a real `.framework` target.
556 The Rust static library is force-loaded INTO this framework.
557
558 ```swift
559 // macos/Classes/OndeInferencePlugin.swift
560 import FlutterMacOS
561
562 public class OndeInferencePlugin: NSObject, FlutterPlugin {
563 public static func register(with registrar: FlutterPluginRegistrar) {
564 // No-op — all FFI symbols come from the Rust static library.
565 }
566 }
567 ```
568
569 ```swift
570 // ios/Classes/OndeInferencePlugin.swift
571 import Flutter // NOT FlutterMacOS
572
573 public class OndeInferencePlugin: NSObject, FlutterPlugin {
574 public static func register(with registrar: FlutterPluginRegistrar) {}
575 }
576 ```
577
578 ### macOS podspec — full working version
579
580 ```ruby
581 Pod::Spec.new do |s|
582 s.name = 'onde_inference'
583 s.version = '0.1.0'
584 s.summary = 'On-device LLM inference SDK for Flutter (macOS).'
585 s.description = 'Runs Qwen 2.5 models locally on macOS with Metal acceleration.'
586 s.homepage = 'https://ondeinference.com'
587 s.license = { :type => 'MIT', :file => '../LICENSE' }
588 s.author = { 'Onde Inference' => 'hello@ondeinference.com' }
589 s.source = { :path => '.' }
590 s.source_files = 'Classes/**/*'
591 s.dependency 'FlutterMacOS'
592 s.platform = :osx, '10.15'
593 s.swift_version = '5.0'
594
595 s.script_phases = [
596 {
597 :name => 'Build Rust bridge (onde_inference_dart)',
598 :script => <<~SHELL,
599 set -e
600 RUST_DIR="${PODS_TARGET_SRCROOT}/../rust"
601 cd "$RUST_DIR"
602 ARCH=$(uname -m)
603 if [[ "$ARCH" == "arm64" ]]; then
604 TARGET="aarch64-apple-darwin"
605 else
606 TARGET="x86_64-apple-darwin"
607 fi
608 cargo build --release --target "$TARGET"
609 cp "target/$TARGET/release/libonde_inference_dart.a" \
610 "${PODS_TARGET_SRCROOT}/libonde_inference_dart.a"
611 SHELL
612 :execution_position => :before_compile,
613 :output_files => ["${PODS_TARGET_SRCROOT}/libonde_inference_dart.a"],
614 }
615 ]
616
617 # SystemConfiguration is required by Rust hyper_util (proxy detection)
618 s.frameworks = 'SystemConfiguration'
619
620 s.pod_target_xcconfig = {
621 'DEFINES_MODULE' => 'YES',
622 'OTHER_LDFLAGS' => '-force_load ${PODS_TARGET_SRCROOT}/libonde_inference_dart.a',
623 }
624 s.preserve_paths = '../rust/**/*'
625 end
626 ```
627
628 **Key points:**
629 - `s.frameworks = 'SystemConfiguration'` — required because the Rust `hyper_util`
630 crate (transitive via `reqwest`) references `SCDynamicStoreCopyProxies`,
631 `SCNetworkReachabilityCreateWithName`, etc. Without it you get
632 `ld: symbol(s) not found for architecture arm64`.
633 - `-force_load` links ALL symbols from the `.a` into the pod framework.
634 - `s.source_files = 'Classes/**/*'` picks up the dummy Swift plugin.
635
636 ### iOS podspec — differs from macOS
637
638 ```ruby
639 s.dependency 'Flutter' # NOT 'FlutterMacOS'
640 s.platform = :ios, '13.0' # NOT :osx
641
642 # Target selection differs:
643 # iphonesimulator → aarch64-apple-ios-sim
644 # iphoneos → aarch64-apple-ios
645
646 s.pod_target_xcconfig = {
647 'DEFINES_MODULE' => 'YES',
648 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
649 'OTHER_LDFLAGS' =>
650 '-force_load ${PODS_TARGET_SRCROOT}/libonde_inference_dart.a',
651 }
652 ```
653
654 ### Android `CMakeLists.txt` — key section
655
656 ```cmake
657 if(ANDROID_ABI STREQUAL "arm64-v8a")
658 set(CARGO_TARGET "aarch64-linux-android")
659 elseif(ANDROID_ABI STREQUAL "armeabi-v7a")
660 set(CARGO_TARGET "armv7-linux-androideabi")
661 elseif(ANDROID_ABI STREQUAL "x86_64")
662 set(CARGO_TARGET "x86_64-linux-android")
663 elseif(ANDROID_ABI STREQUAL "x86")
664 set(CARGO_TARGET "i686-linux-android")
665 endif()
666 add_custom_command(OUTPUT "${LIB_OUTPUT}"
667 COMMAND cargo build --release --target ${CARGO_TARGET}
668 --manifest-path "${RUST_DIR}/Cargo.toml"
669 WORKING_DIRECTORY ${RUST_DIR})
670 add_library(onde_inference_dart SHARED IMPORTED GLOBAL)
671 set_target_properties(onde_inference_dart PROPERTIES IMPORTED_LOCATION ${LIB_OUTPUT})
672 ```
673
674 Android uses CMake to invoke `cargo build` for the right NDK triple, producing
675 a `.so` shared library (unlike Apple which uses `.a` staticlibs).
676
677 ### Android NDK Linker Config (`rust/.cargo/config.toml`)
678
679 ```toml
680 [target.aarch64-linux-android]
681 linker = "aarch64-linux-android24-clang"
682
683 [target.armv7-linux-androideabi]
684 linker = "armv7a-linux-androideabi24-clang"
685
686 [target.x86_64-linux-android]
687 linker = "x86_64-linux-android24-clang"
688
689 [target.i686-linux-android]
690 linker = "i686-linux-android24-clang"
691 ```
692
693 These short names require the NDK toolchain `bin/` to be on PATH.
694 Flutter's Gradle + CMake integration typically handles this automatically
695 during `flutter build apk` / `flutter run -d <android>`.
696
697 ---
698
699 ## Testing Strategy
700
701 All pure-Dart unit tests run **without** building the native binary.
702
703 ```
704 Tests that PASS pre-codegen (using the stub):
705 ✓ All type constructors, equality, hashCode, copyWith
706 ✓ Enum toString and isReady helpers
707 ✓ SamplingConfig presets (default, deterministic, mobile)
708 ✓ GgufModelConfig factory functions (real hardcoded values in stub)
709 ✓ RustLib.init() is idempotent
710 ✓ OndeInference.* static helpers
711 ✓ OndeChatEngine.create() throws UnimplementedError pre-codegen
712
713 Tests that require codegen + native binary:
714 ✗ Any actual inference call
715 ✗ loadDefaultModel / sendMessage / streamMessage
716 ```
717
718 Run tests:
719 ```bash
720 cd sdk/dart && dart test test/dart_test.dart
721 ```
722
723 ---
724
725 ## Pub.dev Readiness Checklist
726
727 Run this before declaring the SDK ready:
728
729 ```bash
730 # 1. Dart analysis — must be zero issues
731 dart analyze lib/
732
733 # 2. All tests pass
734 dart test test/dart_test.dart
735
736 # 3. Dry-run publish — must show compressed size < 100 MB and minimal warnings
737 dart pub publish --dry-run
738 ```
739
740 Common pub.dev score deductions to avoid:
741 - Missing `repository:` in pubspec → loses points
742 - No `example/` directory → loses points
743 - `description:` under 60 chars or over 180 chars → loses points
744 - Library exports not documented → loses points
745 - `library <name>;` directive → triggers lint
746
747 ### Pub.dev Upload Size (100 MB limit)
748
749 `dart pub publish` bundles everything not excluded by `.pubignore` (or
750 `.gitignore` if `.pubignore` doesn't exist). The Rust `target/` dir alone
751 can be **7+ GB**. A `.pubignore` file is mandatory.
752
753 **Files that MUST be included** (consumers build from source):
754 - `rust/src/`, `rust/Cargo.toml`, `rust/Cargo.lock`
755 - `ios/`, `macos/`, `android/`, `linux/`, `windows/` (build scripts, podspecs, CMakeLists)
756 - `ios/Classes/`, `macos/Classes/` (dummy plugin Swift files)
757 - `lib/` (all Dart code)
758
759 **Files that MUST be excluded** (`.pubignore`):
760 - `rust/target/` — multi-GB build artifacts
761 - `**/*.a`, `**/*.so`, `**/*.dylib`, `**/*.dll` — precompiled binaries
762 - `.dart_tool/`, `pubspec.lock`
763 - `example/build/`, `example/.dart_tool/`
764 - `logs/`, `.idea/`, `.vscode/`, `.DS_Store`
765
766 After adding `.pubignore`, verify: `dart pub publish --dry-run` should report
767 ~280 KB compressed size, not hundreds of MB.
768
769 ---
770
771 ## Codegen Workflow (Full, Correct Order)
772
773 ```bash
774 # ── Prerequisites (one-time) ──────────────────────────────────────────────
775
776 rustup target add aarch64-apple-ios aarch64-apple-ios-sim # iOS
777 rustup target add aarch64-apple-darwin x86_64-apple-darwin # macOS
778
779 cargo install cargo-expand # Required by FRB codegen parser
780 cargo install flutter_rust_bridge_codegen # The codegen tool itself
781
782 # ── Per-package setup ─────────────────────────────────────────────────────
783
784 cd sdk/dart
785
786 # IMPORTANT: delete the pre-codegen Rust stub first
787 rm -f rust/src/frb_generated.rs
788
789 # Run codegen — generates:
790 # rust/src/frb_generated.rs (Rust glue)
791 # lib/src/frb_generated.dart (Dart FFI bindings, replaces stub)
792 flutter_rust_bridge_codegen generate
793
794 # Verify Rust still compiles cleanly
795 cd rust && cargo check
796
797 # Verify Dart analysis is still clean
798 cd .. && dart analyze lib/
799 ```
800
801 ---
802
803 ## `flutter run` on iOS (Full Steps)
804
805 ```bash
806 # 1. Scaffold the example Flutter project (one-time)
807 cd sdk/dart/example
808 flutter create . \
809 --project-name <example_name> \
810 --platforms=ios,macos \
811 --org com.<your_org>
812
813 # 2. Get dependencies
814 flutter pub get
815
816 # 3. Run codegen (from the SDK package root, not example root)
817 cd ..
818 flutter_rust_bridge_codegen generate
819
820 # 4. Run the app (from example dir)
821 cd example
822 flutter run # picks up connected device/simulator automatically
823 flutter run -d iPhone # target a specific device
824 ```
825
826 **What happens during the first `flutter run` on iOS:**
827 1. Flutter invokes `pod install` → reads `ios/onde_inference.podspec`
828 2. The podspec script phase runs `cargo build --release --target aarch64-apple-ios-sim`
829 (or `-device`) inside `sdk/dart/rust/` — this takes 5–15 min first time
830 3. The resulting `libonde_inference_dart.a` is linked into the Runner app
831 4. Flutter's Dart VM starts, calls `RustLib.init()`, loads the native library
832 5. The model downloads from HuggingFace Hub on first inference (~941 MB or ~1.93 GB)
833
834 **HuggingFace token for private/gated models:**
835 ```bash
836 # Bake token at build time (required for iOS — no filesystem access):
837 export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
838 flutter run
839 ```
840
841 ---
842
843 ## Version Pinning Rule
844
845 The `flutter_rust_bridge` Dart package and `flutter_rust_bridge` Rust crate
846 **must be the same version**. Use exact pins:
847
848 ```toml
849 # rust/Cargo.toml
850 flutter_rust_bridge = "=2.12.0"
851 ```
852 ```yaml
853 # pubspec.yaml
854 dependencies:
855 flutter_rust_bridge: ^2.12.0 # ^ is OK on Dart side; codegen auto-upgrades
856 ```
857
858 If they drift, generated code won't compile. Always check both when upgrading.
859
860 ---
861
862 ## Common Errors and Fixes
863
864 | Error | Cause | Fix |
865 |-------|-------|-----|
866 | `rust_input: invalid type: sequence` | YAML list syntax in FRB 2.x | Change to `rust_input: "crate::api"` |
867 | `Please migrate configuration rust_input` | File path in FRB 2.x | Change to `rust_input: "crate::api"` |
868 | `File exists (os error 17)` | `frb_generated.rs` pre-exists | `rm rust/src/frb_generated.rs` |
869 | `could not find package flutter_rust_bridge_codegen` | Added codegen as Dart dep | Remove it; install via `cargo install` |
870 | `no such command: expand` | `cargo-expand` not installed | `cargo install cargo-expand` |
871 | `unexpected cfg condition name: frb_expand` | Rust check-cfg lint | Harmless FRB macro warning; suppress with `#[allow(unexpected_cfgs)]` or ignore |
872 | `no_leading_underscores_for_library_prefixes` | Import alias `as _frb` | Rename to `as frb` |
873 | `unnecessary_library_name` | `library <name>;` in barrel | Change to bare `library;` |
874 | `OndeChatEngine.create() throws UnimplementedError` | Codegen not run yet | Run `flutter_rust_bridge_codegen generate` |
875 | `flutter run` fails, missing Runner.xcodeproj | `flutter create` not run | Run `flutter create . --platforms=ios ...` in example dir |
876 | `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 |
877 | `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')` |
878 | `ld: symbol(s) not found ... _kSCNetworkReachability...` | Missing `SystemConfiguration` framework in podspec | Add `s.frameworks = 'SystemConfiguration'` to macOS podspec |
879 | `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) |
880 | `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 |
881 | `EntityTooLarge` on `dart pub publish` | Package > 100 MB (rust/target/ included) | Create `.pubignore` excluding `rust/target/`, `**/*.a`, `**/*.so`, etc. |
882 | `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. |
883 | `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. |
884
885 ## Xcode 26 Compatibility
886
887 ### The NDK-on-PATH Trap
888
889 If your `~/.zshrc` (or similar) has **global** Android NDK toolchain exports:
890
891 ```bash
892 # DANGEROUS — breaks ALL non-Android builds (Xcode, Rust for Apple, etc.)
893 export TOOLCHAIN=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64
894 export LD=$TOOLCHAIN/bin/ld # ← THIS HIJACKS XCODE'S LINKER
895 export AR=$TOOLCHAIN/bin/llvm-ar
896 export CXX=$TOOLCHAIN/bin/clang++
897 export RANLIB=$TOOLCHAIN/bin/llvm-ranlib
898 export STRIP=$TOOLCHAIN/bin/llvm-strip
899 ```
900
901 Xcode reads `$LD` and invokes the Android NDK's LLD instead of Apple's linker.
902 The errors look like Xcode 26 ld-prime incompatibility (`unknown argument '-Xlinker'`)
903 but the root cause is the **wrong linker binary**.
904
905 **Fix:** Comment out all NDK tool exports. Keep only the env vars:
906
907 ```bash
908 export NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865"
909 export ANDROID_NDK="$NDK_HOME"
910 export ANDROID_NDK_HOME="$NDK_HOME"
911 # DO NOT export LD, AR, CXX, RANLIB, STRIP globally.
912 # Cargo uses rust/.cargo/config.toml for Android linker selection.
913 ```
914
915 ### Does Xcode 26's ld-prime work with Flutter?
916
917 **Yes** — once the NDK tool exports are removed, Apple's ld-prime handles all
918 CocoaPods/Flutter linker flags correctly. No `ALTERNATE_LINKER = classic` or
919 `-ld_classic` workaround is needed for macOS builds. The `-ld_classic`
920 deprecation warning from iOS Podfiles is harmless.
921
922 ### iOS 26 Debug Mode Crash
923
924 Flutter debug builds crash on iOS 26 beta with
925 `NOTIFY_DEBUGGER_ABOUT_RX_PAGES was not intercepted as expected`
926 ([flutter#170416](https://github.com/flutter/flutter/issues/170416)).
927 This is a Dart VM JIT issue — **not fixable in the SDK**.
928
929 | Mode | iOS 26 Physical | iOS 26 Simulator | iOS 18.x |
930 |------|----------------|-----------------|----------|
931 | `--debug` | ❌ Dart VM crash | ❌ Dart VM crash | ✅ Works |
932 | `--profile` | ✅ Works | ❌ Not supported on sim | ✅ Works |
933 | `--release` | ⚠️ Use `flutter build ipa` | ❌ Not supported on sim | ✅ Works |
934
935 **Workaround:** Use an iOS 18.x simulator runtime (download via Xcode Settings → Platforms).
936
937 ---
938
939 ## File Interdependency Map
940
941 ```
942 pubspec.yaml
943 └── declares: flutter_rust_bridge ^2.12.0
944
945 flutter_rust_bridge.yaml
946 ├── rust_root: "rust/" → rust/Cargo.toml
947 ├── rust_input: "crate::api" → rust/src/api.rs (source of truth)
948 └── dart_output: "lib/src/frb_generated.dart" (codegen writes here)
949
950 rust/Cargo.toml
951 ├── crate-type = ["cdylib", "staticlib"]
952 ├── flutter_rust_bridge = "=2.12.0" (exact pin!)
953 └── onde = { path = "../../../" } (the main onde crate)
954
955 rust/.cargo/config.toml
956 └── Android NDK linker overrides (aarch64, armv7, x86_64, i686)
957
958 rust/src/api.rs
959 ├── imports onde::{ChatEngine, ...} (path dep → ../../..)
960 ├── declares mirror types (ChatRole, ChatMessage, ...)
961 ├── declares OndeChatEngine (wraps ChatEngine)
962 └── declares free functions (default_model_config, ...)
963
964 lib/src/frb_generated.dart/ ← DIRECTORY (not a file!)
965 ├── api.dart ← generated API bindings
966 ├── api.freezed.dart ← freezed union types (OndeError)
967 ├── frb_generated.dart ← RustLib class, ExternalLibraryLoaderConfig
968 └── frb_generated.io.dart ← RustLibWire, platform FFI glue
969
970 lib/src/types.dart ← hand-written, FRB-independent
971 └── ChatRole, ChatMessage, SamplingConfig, GgufModelConfig,
972 InferenceResult, StreamChunk, EngineStatus, EngineInfo, OndeException
973
974 lib/src/engine.dart ← hand-written, imports frb_generated.dart/ + types.dart
975 ├── extension OndeChatEngineX (loadDefaultModel, clearHistoryCount)
976 └── abstract class OndeInference
977 ├── init() → ExternalLibrary.open('onde_inference.framework/...') on Apple
978 ├── defaultModelConfig(), qwen2515bConfig(), qwen253bConfig(), ...
979 └── defaultSamplingConfig(), deterministicSamplingConfig(), mobileSamplingConfig()
980
981 lib/onde_inference.dart (barrel)
982 ├── export 'src/types.dart'
983 └── export 'src/engine.dart'
984
985 macos/
986 ├── Classes/OndeInferencePlugin.swift ← dummy (required for CocoaPods framework)
987 └── onde_inference.podspec ← script_phase + -force_load + SystemConfiguration
988
989 ios/
990 ├── Classes/OndeInferencePlugin.swift ← dummy (required for CocoaPods framework)
991 └── onde_inference.podspec ← script_phase + -force_load
992
993 android/
994 ├── CMakeLists.txt ← invokes cargo build per ABI
995 └── build.gradle ← externalNativeBuild → cmake
996
997 .pubignore ← excludes rust/target/, *.a, *.so, etc.
998 .gitignore ← excludes same + build artifacts
999 ```
1000
1001 ---
1002
1003 *Last updated: July 2025 — Xcode 26 compatibility, ExternalLibrary.open pattern,
1004 SystemConfiguration framework, .pubignore for pub.dev, NDK PATH pitfall.*