1 ---
2 name: sdk-react-native
3 description: Build, debug, and maintain the Onde Inference React Native / Expo SDK. Covers Expo native module wiring, Metro config for file-linked SDKs, iOS deployment target and Expo autolinking, XCFramework device/simulator split, app group entitlement, HF_HOME/TMPDIR sandbox setup, the native bridge JSON string contract, EngineStatus normalization, and npm publishing.
4 allowed-tools: Read, Write, Edit, Glob, Grep, Bash
5 user-invocable: true
6 ---
7
8 # Skill: React Native / Expo SDK
9
10 ## What This Skill Covers
11
12 Building and maintaining the `@ondeinference/react-native` Expo native module,
13 wiring the Rust C FFI bridge to TypeScript, debugging Metro/CocoaPods/Xcode
14 issues, and publishing to npm.
15
16 ---
17
18 ## Repository Layout
19
20 ```
21 onde/sdk/react-native/
22 ├── package.json # name: @ondeinference/react-native
23 ├── expo-module.config.json # declares OndeInferenceModule for Expo autolinking
24 ├── tsconfig.json # target ES2020, module commonjs
25 ├── src/
26 │ ├── index.ts # public API: OndeChatEngine, free functions, types
27 │ ├── OndeInferenceModule.ts # requireNativeModule("OndeInference") — raw bridge
28 │ └── types.ts # ChatMessage, SamplingConfig, EngineInfo, etc.
29 ├── build/ # tsc output — gitignored, regenerated by npm run build
30 ├── ios/
31 │ ├── OndeInference.podspec # CocoaPods spec — vendored_frameworks = XCFramework
32 │ ├── OndeInferenceModule.swift # Expo Module: Rust C FFI declarations + module def
33 │ └── rust/
34 │ ├── OndeReactNative.xcframework # bundles device + simulator slices
35 │ ├── device/libonde_react_native.a
36 │ └── simulator/libonde_react_native.a
37 ├── android/ # Android module (not yet wired for Onde)
38 ├── scripts/
39 │ └── build-rust.sh # builds Rust .a for iOS device + sim, creates XCFramework
40 └── example/
41 ├── app.json # Expo config — ios.deploymentTarget, appleTeamId
42 ├── babel.config.js # babel-preset-expo only
43 ├── metro.config.js # watchFolders, extraNodeModules, nodeModulesPaths, blockList
44 ├── package.json # dep: "@ondeinference/react-native": "file:.."
45 └── ios/
46 ├── Podfile # uses use_expo_modules!, deployment from Podfile.properties.json
47 ├── Podfile.properties.json # ios.deploymentTarget: "16.0" — critical, see below
48 ├── OndeExample/
49 │ ├── OndeExample.entitlements
50 │ └── Info.plist
51 └── Pods/ # gitignored
52 ```
53
54 ---
55
56 ## Critical Lessons (Read First)
57
58 ### 1. Metro `@babel/runtime` resolution with `file:..` local packages
59
60 **Symptom:** `Unable to resolve module @babel/runtime/helpers/asyncToGenerator`
61 from inside the SDK's `build/index.js`.
62
63 **Root cause:** When Metro processes a file physically located outside the
64 example app's directory (e.g. in `sdk/react-native/build/`), Babel transforms
65 `async/await` and injects `require("@babel/runtime/...")`. Metro resolves
66 modules relative to the file's physical location — but the SDK's own
67 `node_modules/` is blocked (to avoid react-native version conflicts) and the
68 example's `node_modules/` is not in the resolution path for files outside the
69 example root.
70
71 **Fix — always required in `metro.config.js`:**
72
73 ```js
74 // Falls back to the example's node_modules for any file in watched folders
75 config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")];
76 ```
77
78 Also required: block the SDK's node_modules to avoid react-native version
79 conflicts:
80
81 ```js
82 config.resolver.blockList = [
83 new RegExp(
84 path.resolve(sdkRoot, "node_modules").replace(/\\/g, "\\\\") + "/.*",
85 ),
86 ];
87 ```
88
89 ---
90
91 ### 2. Expo autolinking silently skips pods when deployment target is too low
92
93 **Symptom:** `pod install` completes but `OndeInference` is not in
94 `Podfile.lock`. The app launches but throws "Cannot find native module
95 'OndeInference'" at runtime.
96
97 **Root cause:** Expo's autolinking Ruby code calls `pod.supports_platform?`
98 on each discovered pod. The `OndeInference` podspec declares
99 `s.platforms = { :ios => '16.0' }`. If the project's deployment target
100 (from `Podfile.properties.json`) is lower (e.g. the Expo default of 15.1),
101 `supports_platform?` returns false and the pod is **silently skipped** with no
102 error or warning.
103
104 **Diagnosis:**
105
106 ```bash
107 xcrun simctl get_app_container booted <bundle-id> data
108 grep "OndeInference" ios/Podfile.lock # if empty → autolinking skipped it
109 ```
110
111 **Fix — three places must all agree on iOS 16.0:**
112
113 1. `sdk/react-native/ios/OndeInference.podspec` — `s.platforms = { :ios => '16.0' }`
114 2. `example/ios/Podfile.properties.json` — `"ios.deploymentTarget": "16.0"`
115 3. `example/app.json` → `expo.ios.deploymentTarget: "16.0"`
116
117 And after changing `Podfile.properties.json`, manually patch the Xcode project:
118
119 ```bash
120 sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = 15.1/IPHONEOS_DEPLOYMENT_TARGET = 16.0/g' \
121 ios/OndeExample.xcodeproj/project.pbxproj
122 ```
123
124 Then run `pod install` again and verify:
125
126 ```bash
127 grep "OndeInference" ios/Podfile.lock # must now appear
128 ```
129
130 **Important:** `expo prebuild --clean` regenerates `Podfile.properties.json`
131 from `app.json`. After `--clean`, you must also manually re-add
132 `"ios.deploymentTarget": "16.0"` to `Podfile.properties.json` if it was not
133 picked up from `app.json`.
134
135 ---
136
137 ### 3. iOS device vs simulator static library — use an XCFramework
138
139 **Symptom:** Build succeeds for simulator but fails with:
140 `ld: building for 'iOS-simulator', but linking in object file built for 'iOS'`
141
142 **Root cause:** Both iOS device (`aarch64-apple-ios`) and iOS simulator
143 (`aarch64-apple-ios-sim`) are arm64, so `lipo` cannot merge them into a fat
144 binary. They have different Mach-O platform metadata.
145
146 **Wrong approach:** Single `libonde_react_native.a` → works for device OR
147 simulator but not both.
148
149 **Wrong approach:** `vendored_libraries` with two files sharing the same
150 filename → CocoaPods reports "conflicting names" error.
151
152 **Correct approach:** XCFramework bundling both slices:
153
154 ```bash
155 # In scripts/build-rust.sh
156 mkdir -p ios/rust/device ios/rust/simulator
157 cp target/aarch64-apple-ios/release/libonde_react_native.a ios/rust/device/
158 cp target/aarch64-apple-ios-sim/release/libonde_react_native.a ios/rust/simulator/
159
160 rm -rf ios/rust/OndeReactNative.xcframework
161 xcodebuild -create-xcframework \
162 -library ios/rust/device/libonde_react_native.a \
163 -library ios/rust/simulator/libonde_react_native.a \
164 -output ios/rust/OndeReactNative.xcframework
165 ```
166
167 Podspec uses `vendored_frameworks` (not `vendored_libraries`):
168
169 ```ruby
170 s.vendored_frameworks = 'rust/OndeReactNative.xcframework'
171 s.pod_target_xcconfig = {
172 'DEFINES_MODULE' => 'YES',
173 'SWIFT_COMPILATION_MODE' => 'wholemodule',
174 'OTHER_LDFLAGS' => '-londe_react_native',
175 }
176 ```
177
178 CocoaPods automatically selects the correct slice for device vs simulator.
179
180 ---
181
182 ### 4. The native bridge JSON string contract — never return Swift dicts
183
184 **Symptom:** `JSON parse error: unexpected character: o`
185
186 **Root cause:** The TypeScript layer receives the native return value and calls
187 `JSON.parse(value)`. If the native Swift module returns `[String: Any]` or
188 `[[String: Any]]` instead of a `String`, the Expo bridge serialises it in an
189 opaque way that is not valid JSON (the string representation starts with
190 something like `{...}` or `[...]` in a non-JSON form that starts with `o`
191 when printed).
192
193 **The contract:** All Expo Module methods in `OndeInferenceModule.swift` that
194 return complex types MUST return `String` (the raw JSON from Rust), NOT
195 `[String: Any]` or `[[String: Any]]`. The TypeScript layer owns all JSON
196 parsing.
197
198 **Correct Swift:**
199
200 ```swift
201 AsyncFunction("loadDefaultModel") {
202 (systemPrompt: String?, samplingJson: String?) -> String in // ← String, not [String: Any]
203 ...
204 return try self.validateJsonResult(result) // returns the raw JSON string
205 }
206
207 Function("defaultModelConfig") { () -> String in // ← String, not [String: Any]
208 let result = self.consumeRustString(onde_default_model_config())
209 return try self.validateJsonResult(result)
210 }
211 ```
212
213 **Validation helper — validate but do not deserialise:**
214
215 ```swift
216 private func validateJsonResult(_ json: String?) throws -> String {
217 guard let json = json,
218 let data = json.data(using: .utf8),
219 let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any]
220 else { throw OndeError.invalidResponse }
221 if let error = dict["error"] as? String {
222 throw OndeError.inferenceError(error)
223 }
224 return json // ← return the original string, not the parsed dict
225 }
226
227 private func validateJsonArray(_ json: String?) throws -> String {
228 guard let json = json,
229 let data = json.data(using: .utf8),
230 (try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) != nil
231 else { throw OndeError.invalidResponse }
232 return json // ← return the original string
233 }
234 ```
235
236 ---
237
238 ### 5. EngineStatus is lowercase from Rust — normalize in the UI layer
239
240 **Symptom:** Model loads successfully. Alert says "Ready in X.Xs". But the
241 `TextInput` placeholder stays at "Load a model first" and remains non-editable.
242
243 **Root cause:** Rust's `Display` impl emits lowercase status strings:
244 `"ready"`, `"unloaded"`, `"loading"`, `"generating"`, `"error"`.
245 The TypeScript UI uses capitalized union type values (`"Ready"`, `"Unloaded"`,
246 etc.). After `refreshStatus()` runs, `engineStatus` holds `"ready"` which
247 does not match `"Ready"` in the `isLoaded` check.
248
249 **Fix — normalize at the point of receiving native status:**
250
251 ```typescript
252 const normalizeEngineStatus = useCallback((status: string): EngineStatus => {
253 switch (status.toLowerCase()) {
254 case "loading": return "Loading";
255 case "ready": return "Ready";
256 case "generating": return "Generating";
257 case "error": return "Error";
258 default: return "Unloaded";
259 }
260 }, []);
261
262 const refreshStatus = useCallback(async () => {
263 try {
264 const info = await OndeChatEngine.info();
265 setEngineStatus(normalizeEngineStatus(info.status)); // ← normalize here
266 setModelName(info.modelName ?? null);
267 setApproxMemory(info.approxMemory ?? null);
268 } catch { /* best-effort */ }
269 }, [normalizeEngineStatus]);
270 ```
271
272 ---
273
274 ### 6. iOS sandbox filesystem setup — HF_HOME must be set before model load
275
276 **Symptom:** Model load fails immediately with "error 0" or returns an error
277 containing "HF_HOME is not set".
278
279 **Root cause:** On iOS, `~/.cache` is outside the app sandbox. The Rust engine
280 explicitly requires `HF_HOME` to be set before any model load on sandboxed
281 platforms. Without it, `load_gguf_model` returns an `InferenceError::ModelBuild`
282 with the message: *"HF_HOME is not set — cannot initialise HF cache."*
283
284 **Fix — call `configureApplicationFilesystem()` in `OnCreate` and before each
285 model load:**
286
287 ```swift
288 private func configureApplicationFilesystem() throws {
289 let fileManager = FileManager.default
290
291 let containerDirectory: URL
292 if let appGroupDirectory = fileManager.containerURL(
293 forSecurityApplicationGroupIdentifier: ondeAppGroupIdentifier
294 ) {
295 containerDirectory = appGroupDirectory
296 } else {
297 containerDirectory = try fileManager.url(
298 for: .applicationSupportDirectory,
299 in: .userDomainMask, appropriateFor: nil, create: true
300 )
301 }
302
303 // Convention: <container>/models/hub — matches Tauri and Swift SDKs exactly
304 let hfHomeDirectory = containerDirectory.appendingPathComponent("models", isDirectory: true)
305 let hfHubCacheDirectory = hfHomeDirectory.appendingPathComponent("hub", isDirectory: true)
306 let tempDirectory = containerDirectory.appendingPathComponent("tmp", isDirectory: true)
307
308 try fileManager.createDirectory(at: hfHomeDirectory, withIntermediateDirectories: true)
309 try fileManager.createDirectory(at: hfHubCacheDirectory, withIntermediateDirectories: true)
310 try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
311
312 setenv("HF_HOME", hfHomeDirectory.path, 1)
313 setenv("HF_HUB_CACHE", hfHubCacheDirectory.path, 1)
314 setenv("HUGGINGFACE_HUB_CACHE", hfHubCacheDirectory.path, 1)
315 setenv("TMPDIR", tempDirectory.path, 1)
316 }
317 ```
318
319 Call it in `OnCreate` **and** at the start of `loadDefaultModel`/`loadModel`:
320
321 ```swift
322 OnCreate {
323 do {
324 try self.configureApplicationFilesystem()
325 self.enginePtr = onde_engine_create()
326 } catch {
327 self.enginePtr = nil
328 }
329 }
330
331 AsyncFunction("loadDefaultModel") { ... -> String in
332 try self.configureApplicationFilesystem() // ← also here, idempotent
333 guard let ptr = self.enginePtr else { throw OndeError.engineNotInitialized }
334 ...
335 }
336 ```
337
338 ---
339
340 ### 7. Shared app group cache path convention
341
342 All Onde-powered apps across all SDKs (Tauri, Swift, React Native, Flutter)
343 share the same HuggingFace cache via the App Group container. The layout is:
344
345 ```
346 <group.com.ondeinference.apps container>/
347 ├── models/ ← HF_HOME
348 │ └── hub/ ← HF_HUB_CACHE (GGUF files live here)
349 └── tmp/ ← TMPDIR
350 ```
351
352 **The subdirectory is `models/`, NOT `huggingface/`.**
353
354 Do NOT use `OndeInference/huggingface/hub/` — this was an early mistake that
355 caused the app to look in a different place than the Tauri SDK, preventing
356 cross-app model reuse.
357
358 Verifying the shared container is accessible on simulator:
359
360 ```bash
361 xcrun simctl get_app_container booted <bundle-id> groups
362 # → shows group.com.ondeinference.apps path
363
364 xcrun simctl get_app_container booted <bundle-id> group.com.ondeinference.apps
365 # → resolves the exact path
366
367 # Check whether models exist in the shared cache
368 find "$(xcrun simctl get_app_container booted <bundle-id> group.com.ondeinference.apps)/models/hub" \
369 -name "*.gguf" -o -name "*.part" -o -name "*.lock" | sort
370 ```
371
372 ---
373
374 ### 8. Entitlements required for iOS
375
376 The example app's `OndeExample.entitlements` must contain:
377
378 ```xml
379 <?xml version="1.0" encoding="UTF-8"?>
380 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
381 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
382 <plist version="1.0">
383 <dict>
384 <key>com.apple.developer.kernel.increased-memory-limit</key>
385 <true />
386 <key>com.apple.security.application-groups</key>
387 <array>
388 <string>group.com.ondeinference.apps</string>
389 </array>
390 </dict>
391 </plist>
392 ```
393
394 - `increased-memory-limit` — required for ~1 GB+ model loading on iOS
395 - `com.apple.security.application-groups` — grants access to the shared model cache
396
397 **On physical device:** the App Group must be registered in the Apple Developer
398 Portal under **Identifiers → App Groups** before Xcode can provision it.
399
400 On the iOS Simulator the entitlement is granted automatically by the simulator
401 runtime — no provisioning profile needed.
402
403 ---
404
405 ### 9. FlatList swallows taps — TextInput never gets focus
406
407 **Symptom:** After model loads, the `TextInput` placeholder is correct but
408 tapping it does nothing. The keyboard never appears.
409
410 **Root cause:** `FlatList` on iOS intercepts touch events and by default
411 dismisses the keyboard on scroll, which prevents child inputs from receiving
412 focus when tapping in certain layout configurations.
413
414 **Fix:**
415
416 ```tsx
417 <FlatList
418 ...
419 keyboardShouldPersistTaps="handled" // ← prevents FlatList eating taps
420 />
421 ```
422
423 ---
424
425 ### 10. Do not use the iOS Simulator to validate model loading
426
427 The iOS Simulator is unreliable for validating actual LLM model loading with
428 this stack:
429 - Metal-backed inference behaves differently in the simulator
430 - Large GGUF model loading (~941 MB) may hang or fail in the simulator
431 - Shared app group container behavior can differ
432
433 Use the Simulator for:
434 - Build verification
435 - UI layout and interaction
436 - Entitlement/container path checks (`xcrun simctl get_app_container ...`)
437
438 Use a **physical iPhone running iOS 16+** for:
439 - Actual model download and load
440 - Metal inference correctness
441 - Memory behavior
442
443 ---
444
445 ### 11. Diagnosing a hung model load
446
447 When the app stays in "Loading" state indefinitely:
448
449 1. **Check the shared container has no stale partial downloads:**
450
451 ```bash
452 # Find .part and .lock files
453 find "$(xcrun simctl get_app_container booted <bundle-id> group.com.ondeinference.apps)" \
454 -type f \( -name "*.part" -o -name "*.lock" \) | sort
455
456 # Remove stale partial caches
457 rm -rf "<container>/models/hub/models--<org>--<model>"
458 ```
459
460 2. **Check the app group resolves:**
461
462 ```bash
463 xcrun simctl get_app_container booted <bundle-id> groups
464 ```
465
466 3. **Check native logs:**
467
468 ```bash
469 xcrun simctl spawn booted log show --style compact --last 5m \
470 --predicate 'process == "OndeExample" AND (
471 eventMessage CONTAINS[c] "HF_" OR
472 eventMessage CONTAINS[c] "models" OR
473 eventMessage CONTAINS[c] "onde"
474 )' | tail -100
475 ```
476
477 4. **Verify `
478 HF_TOKEN` was baked in at Rust build time.** On iOS,
479 `TokenSource::CacheToken` cannot read `~/.cache/huggingface/token` because that
480 path is outside the sandbox. `HF_TOKEN` must be set as an environment variable
481 **when building the Rust static library**:
482
483 ```bash
484 export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx
485 ./scripts/build-rust.sh ios
486 ```
487
488 `option_env!("HF_TOKEN")` in `src/inference/token.rs` bakes it into the binary
489 at compile time. Setting it after the fact has no effect.
490
491 ---
492
493 ## Architecture
494
495 ```
496 ┌──────────────────────────────────────────────────────┐
497 │ React Native / TypeScript │
498 │ OndeChatEngine, free functions (index.ts) │
499 └─────────────────────┬────────────────────────────────┘
500 │ JSON strings over Expo bridge
501 ▼
502 ┌──────────────────────────────────────────────────────┐
503 │ OndeInferenceModule.swift (Expo Module) │
504 │ - configureApplicationFilesystem() │
505 │ - validateJsonResult() / validateJsonArray() │
506 │ - AsyncFunction / Function definitions │
507 └─────────────────────┬────────────────────────────────┘
508 │ C FFI via @_silgen_name
509 ▼
510 ┌──────────────────────────────────────────────────────┐
511 │ libonde_react_native.a (Rust static lib) │
512 │ C API: onde_engine_create, onde_engine_load_*, ... │
513 │ Rust engine: ChatEngine, mistral.rs, Metal/CPU │
514 └──────────────────────────────────────────────────────┘
515 ```
516
517 The Swift module **does not use UniFFI**. It calls the Rust C API directly via
518 `@_silgen_name` declarations, communicating via JSON strings over the Expo
519 Module bridge.
520
521 This is different from the Swift XCFramework SDK (which uses UniFFI-generated
522 `onde.swift`) and the Kotlin SDK (which uses UniFFI-generated `onde.kt`).
523
524 ---
525
526 ## Expo Module Definition Reference
527
528 All methods in `OndeInferenceModule.swift` follow this pattern:
529
530 | Expo construct | Use for |
531 |---|---|
532 | `Function(name)` | Synchronous calls (isLoaded, setSystemPrompt, etc.) |
533 | `AsyncFunction(name)` | All calls that invoke Rust (model load, inference, etc.) |
534 | `OnCreate` | Engine initialisation — run `configureApplicationFilesystem()` here |
535 | `OnDestroy` | Engine cleanup — call `onde_engine_destroy` |
536
537 Return types:
538 - Simple sync functions: `Bool`, `Int`
539 - All complex types: `String` — raw JSON from Rust, **never** `[String: Any]`
540
541 ---
542
543 ## TypeScript Bridge Contract
544
545 ### `OndeInferenceModule.ts` — raw native interface
546
547 All async methods return `Promise<string>` (raw JSON strings from Rust):
548
549 ```typescript
550 export interface OndeInferenceNativeModule {
551 // Sync
552 isLoaded(): boolean;
553 setSystemPrompt(prompt: string): void;
554 clearHistory(): number;
555
556 // Async — all return JSON strings
557 loadDefaultModel(systemPrompt: string | null, samplingJson: string | null): Promise<string>;
558 sendMessage(message: string): Promise<string>;
559 info(): Promise<string>;
560 history(): Promise<string>;
561
562 // Sync config — return JSON strings
563 defaultModelConfig(): string;
564 defaultSamplingConfig(): string;
565 }
566 ```
567
568 ### `index.ts` — public API layer
569
570 Serialises inputs to JSON, calls native, deserialises JSON responses:
571
572 ```typescript
573 // Input serialisation (camelCase TS → snake_case Rust)
574 function serializeSampling(config: SamplingConfig): string {
575 return JSON.stringify({ temperature: config.temperature, top_p: config.topP, ... });
576 }
577
578 // Output deserialisation (snake_case Rust → camelCase TS)
579 function parseResult<T>(json: string): T {
580 const parsed = JSON.parse(json); // JSON.parse — expects a string
581 if (parsed.error) throw new OndeError(parsed.error);
582 return parsed as T;
583 }
584 ```
585
586 ---
587
588 ## Metro Config for `file:..` SDK Development
589
590 ```js
591 const { getDefaultConfig } = require("expo/metro-config");
592 const path = require("path");
593
594 const sdkRoot = path.resolve(__dirname, ".."); // ../sdk/react-native/
595 const config = getDefaultConfig(__dirname);
596
597 // 1. Watch the SDK so Metro picks up changes
598 config.watchFolders = [sdkRoot];
599
600 // 2. Map the package name to the SDK root
601 config.resolver.extraNodeModules = {
602 "@ondeinference/react-native": sdkRoot,
603 };
604
605 // 3. Allow transitive deps (e.g. @babel/runtime) to resolve from
606 // the example's node_modules when files outside the example are processed
607 config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")];
608
609 // 4. Block the SDK's own node_modules to prevent react-native version conflicts
610 config.resolver.blockList = [
611 new RegExp(
612 path.resolve(sdkRoot, "node_modules").replace(/\\/g, "\\\\") + "/.*",
613 ),
614 ];
615
616 module.exports = config;
617 ```
618
619 ---
620
621 ## Rust C API Surface
622
623 The Swift module binds to these C functions via `@_silgen_name`:
624
625 | Function | Returns | Notes |
626 |---|---|---|
627 | `onde_engine_create` | `UnsafeMutableRawPointer?` | Allocates engine |
628 | `onde_engine_destroy` | `void` | Frees engine |
629 | `onde_engine_load_default_model` | `UnsafeMutablePointer<CChar>?` | JSON result string |
630 | `onde_engine_load_model` | `UnsafeMutablePointer<CChar>?` | JSON result string |
631 | `onde_engine_unload_model` | `UnsafeMutablePointer<CChar>?` | JSON result string |
632 | `onde_engine_is_loaded` | `Bool` | Sync |
633 | `onde_engine_info` | `UnsafeMutablePointer<CChar>?` | JSON result string |
634 | `onde_engine_send_message` | `UnsafeMutablePointer<CChar>?` | JSON result string |
635 | `onde_engine_generate` | `UnsafeMutablePointer<CChar>?` | JSON result string |
636 | `onde_engine_history` | `UnsafeMutablePointer<CChar>?` | JSON array string |
637 | `onde_engine_clear_history` | `UInt64` | Sync, count |
638 | `onde_engine_set_system_prompt` | `void` | Sync |
639 | `onde_engine_set_sampling` | `void` | Sync |
640 | `onde_engine_push_history` | `void` | Sync |
641 | `onde_free_string` | `void` | Must call for every non-null CChar* result |
642 | `onde_default_model_config` | `UnsafeMutablePointer<CChar>?` | JSON result string |
643 | `onde_qwen25_1_5b_config` | `UnsafeMutablePointer<CChar>?` | JSON result string |
644 | `onde_qwen25_3b_config` | `UnsafeMutablePointer<CChar>?` | JSON result string |
645 | `onde_default_sampling_config` | `UnsafeMutablePointer<CChar>?` | JSON result string |
646 | `onde_deterministic_sampling_config` | `UnsafeMutablePointer<CChar>?` | JSON result string |
647 | `onde_mobile_sampling_config` | `UnsafeMutablePointer<CChar>?` | JSON result string |
648
649 **Always call `onde_free_string` after consuming each result pointer.** The
650 Rust side allocates the string; Swift is responsible for freeing it.
651
652 ```swift
653 private func consumeRustString(_ ptr: UnsafeMutablePointer<CChar>?) -> String? {
654 guard let ptr = ptr else { return nil }
655 let str = String(cString: ptr)
656 onde_free_string(ptr) // ← must free every non-null pointer
657 return str
658 }
659 ```
660
661 ---
662
663 ## build-rust.sh — iOS Build Flow
664
665 ```bash
666 # 1. Build both targets
667 cargo build --manifest-path rust/Cargo.toml --target aarch64-apple-ios --release
668 cargo build --manifest-path rust/Cargo.toml --target aarch64-apple-ios-sim --release
669
670 # 2. Copy to separate dirs (same filename — cannot lipo)
671 mkdir -p ios/rust/device ios/rust/simulator
672 cp target/aarch64-apple-ios/release/libonde_react_native.a ios/rust/device/
673 cp target/aarch64-apple-ios-sim/release/libonde_react_native.a ios/rust/simulator/
674
675 # 3. Create XCFramework (CocoaPods picks right slice automatically)
676 rm -rf ios/rust/OndeReactNative.xcframework
677 xcodebuild -create-xcframework \
678 -library ios/rust/device/libonde_react_native.a \
679 -library ios/rust/simulator/libonde_react_native.a \
680 -output ios/rust/OndeReactNative.xcframework
681 ```
682
683 After rebuilding, run `pod install` in `example/ios/` and then
684 `npx expo run:ios --device` or `npx expo run:ios` (simulator).
685
686 ---
687
688 ## Running on Physical Device
689
690 ```bash
691 # From example/
692 npx expo run:ios --device
693 ```
694
695 For a physical device build you need:
696 - Apple Developer team set in `app.json` → `expo.ios.appleTeamId`
697 - The App Group `group.com.ondeinference.apps` registered in the developer portal
698 - The App Group capability enabled on the App ID
699 - `com.apple.developer.kernel.increased-memory-limit` in entitlements
700
701 **Blank white screen on device** = Metro bundle not loading. Ensure:
702 1. Metro is running (`npx expo start --clear`)
703 2. Phone and Mac are on the same network (or use Tunnel)
704 3. Rebuild after native changes (`npx expo run:ios --device` — not just `expo start`)
705
706 ---
707
708 ## .gitignore for the SDK
709
710 ```gitignore
711 node_modules/
712 build/ # tsc output
713 rust/target/ # Rust build artifacts
714 ios/build/
715 ios/rust/*.a
716 ios/rust/*.xcframework
717 ios/rust/device/
718 ios/rust/simulator/
719 android/build/
720 android/src/main/jniLibs/
721 example/.expo/
722 example/android/
723 example/ios/build/
724 example/ios/Pods/
725 example/ios/.xcode.env.local
726 .DS_Store
727 *.xcuserstate
728 ```
729
730 ---
731
732 ## npm Publishing
733
734 ```bash
735 cd sdk/react-native
736 npm run build # tsc → build/
737 npm publish # publishes build/, ios/, android/, scripts/
738 ```
739
740 The `.npmignore` excludes:
741 - `example/`
742 - `rust/`
743 - `node_modules/`
744 - `src/` (consumers use the compiled `build/`)
745
746 The `package.json` `files` field explicitly includes:
747 `build/`, `ios/`, `android/`, `scripts/`, `expo-module.config.json`
748
749 ---
750
751 ## Common Pitfalls Table
752
753 | Pitfall | Symptom | Fix |
754 |---|---|---|
755 | Missing `nodeModulesPaths` in metro.config.js | `Cannot resolve @babel/runtime/helpers/asyncToGenerator` | Add `config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")]` |
756 | Deployment target mismatch | Pod installs but `OndeInference` missing from `Podfile.lock` | Set `"ios.deploymentTarget": "16.0"` in `Podfile.properties.json` AND `app.json` |
757 | Single `.a` file for device + simulator | Linker error: "building for iOS-simulator but linked for iOS" | Use `xcodebuild -create-xcframework` to bundle both slices |
758 | Two `.a` files with same name in `vendored_libraries` | CocoaPods "conflicting names" error | Use `vendored_frameworks` with an XCFramework instead |
759 | Swift returning `[String: Any]` from Expo Module | `JSON parse error: unexpected character: o` | Return `String` from all Expo Module functions; validate but do not deserialise |
760 | `HF_HOME` not set on iOS | "error 0" or model load fails immediately | Call `configureApplicationFilesystem()` in `OnCreate` and before model load |
761 | Wrong cache path (`OndeInference/huggingface/hub/`) | Models not shared across Onde apps | Use `<container>/models/hub/` to match Tauri/Swift convention |
762 | `EngineStatus` lowercase from Rust | Input stays disabled after model loads; status shows as `"ready"` not `"Ready"` | Normalize in `refreshStatus()` with `switch(status.toLowerCase())` |
763 | `FlatList` swallows taps | TextInput can't be focused | Add `keyboardShouldPersistTaps="handled"` to `FlatList` |
764 | Model hangs on Simulator | Stuck in loading indefinitely | Test model loading on a physical device; Simulator is unreliable for this |
765 | Stale `.part`/`.lock` files | Model download never completes on retry | Delete `models--<org>--<model>` from the shared container and retry |
766 | `HF_TOKEN` not baked in | Auth fails on HuggingFace download; "401 Unauthorized" in Rust logs | Set `HF_TOKEN` env var **before** building the Rust static library |
767 | `expo prebuild --clean` wipes `Podfile.properties.json` | Deployment target reverts to 15.1; autolinking drops OndeInference | Re-add `"ios.deploymentTarget": "16.0"` to `Podfile.properties.json` after every `--clean` |
768 | Physical device blank white screen | App launches, nothing renders | Ensure Metro is running and on same network; use `npx expo run:ios --device` |
769 | Running with `expo start` instead of `expo run:ios` | "Cannot find native module 'OndeInference'" | Custom native modules require a native build — use `expo run:ios` not Expo Go |