|
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 | |