diff --git a/docs/docs/01-fundamentals/01-getting-started.md b/docs/docs/01-fundamentals/01-getting-started.md index 9a93c66385..461d81c04a 100644 --- a/docs/docs/01-fundamentals/01-getting-started.md +++ b/docs/docs/01-fundamentals/01-getting-started.md @@ -86,10 +86,12 @@ React Native ExecuTorch requires: - **New Architecture** enabled - **React Native 0.83+** or **Expo SDK 55+** with [Development Builds](https://docs.expo.dev/develop/development-builds/introduction/) (**Expo Go is not supported** due to custom C++ native libraries) - **`react-native-worklets` 0.10 or newer** (`>=0.10.0 <0.13.0`) -- **iOS 17.0+** / **Android 13+** +- **iOS 17.0+** / **Android 13+** (`minSdkVersion` >= 26) For supported React Native versions, see the [Compatibility -table](../05-other/01-compatibility.mdx). +table](../05-other/01-compatibility.mdx). If an install or a build fails, see +[Troubleshooting](../05-other/02-troubleshooting.md) — most of it comes down to +the package manager skipping the postinstall hook, or to `use_frameworks!`. ::: :::caution Expo SDK 55 and 56 diff --git a/docs/docs/03-core-and-advanced/08-native-libraries.md b/docs/docs/03-core-and-advanced/08-native-libraries.md index 63b20c4198..22bee7ea79 100644 --- a/docs/docs/03-core-and-advanced/08-native-libraries.md +++ b/docs/docs/03-core-and-advanced/08-native-libraries.md @@ -47,6 +47,22 @@ Add a `react-native-executorch` block to your `package.json`: The three lists are merged, so you can pair high-level `features` with specific `backends` or `libs`. Re-run your package manager install after editing. +:::note Monorepos +The block is read from the directory the install was run in, then from every +`package.json` above the installed package. A hoisted workspace resolves to the +**root** either way, so put the block there; an app that keeps its own +`node_modules` (pnpm, nohoist) is found from its own `package.json`. +`node_modules/react-native-executorch/rne-build-config.json` records what was +actually resolved, and the install log names the manifest it read. +::: + +:::caution pnpm +pnpm 10 and later do not run dependency build scripts unless you allow them, so +the download never happens and the native build fails later on a missing file. +Run `pnpm approve-builds react-native-executorch` once. See +[Troubleshooting](../05-other/02-troubleshooting.md). +::: + ### Backends Hardware backends provide optimized execution kernels for specific processors and platforms. See the [ExecuTorch Backends documentation](https://docs.pytorch.org/executorch/stable/backends-section.html) for details on lowering and delegate compilation: diff --git a/docs/docs/05-other/02-troubleshooting.md b/docs/docs/05-other/02-troubleshooting.md new file mode 100644 index 0000000000..f5fc608a3b --- /dev/null +++ b/docs/docs/05-other/02-troubleshooting.md @@ -0,0 +1,166 @@ +--- +title: Troubleshooting +slug: /other/troubleshooting +description: 'Build and install failures caused by a project setup React Native ExecuTorch cannot control, and what to do about each.' +keywords: + [react native executorch, troubleshooting, use_frameworks, pnpm, opencv, cocoapods, simulator] +--- + +Failures that come from how a project is set up rather than from the library +itself. Each one is reproducible, so the symptom is quoted exactly — search +this page for the error you got. + +## `Build input files cannot be found` after install + +``` +error: Build input files cannot be found: +'.../XnnpackBackend.xcframework/ios-arm64-simulator/libXnnpackBackend.a' +``` + +The native artifacts are not in the npm tarball. They are downloaded by a +**postinstall** hook, and your package manager skipped it: + +- **pnpm 10 and later block dependency build scripts by default** and print + `Ignored build scripts: react-native-executorch` during install. +- `--ignore-scripts`, and `npm ci --ignore-scripts`, do the same. + +Nothing fails at that point — not even `pod install`, which does not check that +a vendored framework exists — so the error only appears once Xcode goes looking +for the file. Recent versions fail during `pod install` (and during the Android +configure phase) with this fix in the message instead. Re-run the hook: + +```bash +pnpm approve-builds react-native-executorch # pnpm +npm rebuild react-native-executorch # npm +node node_modules/react-native-executorch/scripts/download-libs.js +``` + +## `Multiple commands produce .../Headers/Types.h` + +An app built with `use_frameworks!` — directly, as Firebase requires, or through +expo-build-properties' `"useFrameworks": "static"` — makes CocoaPods build the +pod as a framework and flat-copy its public headers into one directory. +Versions up to 0.10.0 published every header, and several share a basename, so +the build fails while it is still being planned. + +Update the library. On 0.10.0 exactly, force the pod back to a static library: + +```ruby +pre_install do |installer| + installer.pod_targets.each do |pod| + if pod.name == 'react-native-executorch' + def pod.build_type + Pod::BuildType.static_library + end + end + end +end +``` + +Expo SDK 55 and later already do this for you: their autolinking downgrades +every pod that vendors an `.xcframework` to a static library, which is why an +Expo app usually never sees this. Setting `buildReactNativeFromSource: true` +turns that off again. + +## `transitive dependencies that include statically linked binaries` + +``` +[!] The 'Pods-YourApp' target has transitive dependencies that include +statically linked binaries: (.../opencv-rne/opencv2.xcframework) +``` + +`use_frameworks!` with no argument means **dynamic** linkage, which CocoaPods +refuses to combine with a statically linked dependency. Ask for static +frameworks instead: + +```ruby +use_frameworks! :linkage => :static +``` + +## `frameworks with conflicting names: opencv2.xcframework` + +Another pod vendors OpenCV under the same framework name — `react-native-fast-opencv` +(via `FastOpenCV-iOS`) is the common one — and CocoaPods installs only one +framework called `opencv2`. + +Recent versions handle this for you: when `react-native-fast-opencv` is +installed alongside this library, we depend on the OpenCV it vendors instead of +our own, and compile against that copy's headers. `pod install` prints which one +it chose. Nothing to configure, and both libraries work in the same app. + +To force the choice, name the pod that should provide OpenCV: + +```json +{ + "react-native-executorch": { + "opencvPod": "opencv-rne" + } +} +``` + +`opencv-rne` is ours; any pod that vendors an `opencv2.xcframework` is accepted. +We only use `opencv2/core.hpp` and `opencv2/imgproc.hpp`, so an OpenCV 4.x build +serves. Forcing ours while another OpenCV is installed brings the conflict back, +which is what the setting is for when you would rather drop the other library. + +If you do not use this library's vision tasks at all, drop its OpenCV instead: + +```json +{ + "react-native-executorch": { + "backends": ["xnnpack", "coreml", "mlx"], + "libs": ["phonemis"] + } +} +``` + +Re-run your package manager's install afterwards. See +[Native Libraries](../03-core-and-advanced/08-native-libraries.md) for what each +entry covers — leaving `opencv` out disables every computer-vision task. + +## `None of the architectures in ARCHS (x86_64) are valid` + +The library ships `arm64` slices only, and the podspec excludes `x86_64` from +simulator builds. **Intel Macs cannot build for the iOS simulator**, and neither +can an Apple silicon Mac running Xcode under Rosetta, or an Intel macOS CI +image. Use an Apple silicon machine, or a physical device. + +## `The platform of the target ... may not be compatible` + +``` +[!] The platform of the target `YourApp` (iOS 16.4) may not be compatible with +`react-native-executorch (0.10.0)` which has a minimum requirement of iOS 17.0. +``` + +This is a **warning**, so `pod install` still succeeds and the failure surfaces +later. The library needs iOS 17. In an Expo app, set it explicitly — the default +is lower: + +```json +[ + "expo-build-properties", + { "ios": { "deploymentTarget": "17.0" } } +] +``` + +## The `react-native-executorch` config block is ignored + +The postinstall hook reads the block from the directory where the install was +invoked (`INIT_CWD`), then from every `package.json` above the installed +package. In a hoisted monorepo both land on the **workspace root**, so a block +in `apps/mobile/package.json` is never seen — put it in the root `package.json` +instead. The install log names the manifest that won, and +`node_modules/react-native-executorch/rne-build-config.json` records which flags +were written. + +## An old Android device or emulator crashes on load + +Native code is shipped for `arm64-v8a` and `x86_64` only. A build that also +produces `armeabi-v7a` or `x86` splits (React Native's default +`reactNativeArchitectures` lists all four) will package those without the +library's `.so`, and loading it fails at runtime on such a device. Restrict the +app to the supported ABIs: + +```properties +reactNativeArchitectures=arm64-v8a,x86_64 +``` diff --git a/docs/versioned_docs/version-0.10.0/01-fundamentals/01-getting-started.md b/docs/versioned_docs/version-0.10.0/01-fundamentals/01-getting-started.md index bfc9a94e42..4e7931c857 100644 --- a/docs/versioned_docs/version-0.10.0/01-fundamentals/01-getting-started.md +++ b/docs/versioned_docs/version-0.10.0/01-fundamentals/01-getting-started.md @@ -84,11 +84,30 @@ pnpm add react-native-executorch react-native-worklets react-native-blob-util React Native ExecuTorch requires: - **New Architecture** enabled -- **React Native 0.81+** or **Expo SDK 54+** with [Development Builds](https://docs.expo.dev/develop/development-builds/introduction/) (**Expo Go is not supported** due to custom C++ native libraries) -- **iOS 17.0+** / **Android 13+** +- **React Native 0.83+** or **Expo SDK 55+** with [Development Builds](https://docs.expo.dev/develop/development-builds/introduction/) (**Expo Go is not supported** due to custom C++ native libraries) +- **`react-native-worklets` 0.10 or newer** (`>=0.10.0 <0.13.0`) +- **iOS 17.0+** / **Android 13+** (set the app's `minSdkVersion` to 26 or higher) For supported React Native versions, see the [Compatibility -table](../05-other/01-compatibility.mdx). +table](../05-other/01-compatibility.mdx). If an install or a build fails, see +[Troubleshooting](../05-other/02-troubleshooting.md) — most of it comes down to +the package manager skipping the postinstall hook, or to `use_frameworks!`. +::: + +:::caution Expo SDK 55 and 56 +Both bundle a `react-native-worklets` older than 0.10 — 0.7.4 on SDK 55, 0.8.3 +on SDK 56 — and `npx expo install` will pick that one. Install the version this +library needs instead: + +```bash +npm install react-native-worklets@^0.10.0 +``` + +Any other package in your app that uses worklets has to be moved to a release +built against the same version; two of them asking for different worklets gives +you two incompatible native runtimes. Our own example apps reconcile them this +way. **Expo SDK 54 cannot be supported**: it is React Native 0.81, below what +worklets 0.10 accepts. ::: ### Selecting native libraries diff --git a/docs/versioned_docs/version-0.10.0/03-core-and-advanced/08-native-libraries.md b/docs/versioned_docs/version-0.10.0/03-core-and-advanced/08-native-libraries.md index 63b20c4198..ce9f76c506 100644 --- a/docs/versioned_docs/version-0.10.0/03-core-and-advanced/08-native-libraries.md +++ b/docs/versioned_docs/version-0.10.0/03-core-and-advanced/08-native-libraries.md @@ -47,6 +47,20 @@ Add a `react-native-executorch` block to your `package.json`: The three lists are merged, so you can pair high-level `features` with specific `backends` or `libs`. Re-run your package manager install after editing. +:::note Monorepos +The block is read from the directory the install was run in, which in a +workspace is the **root** — a block in an app's own `package.json` is ignored. +`node_modules/react-native-executorch/rne-build-config.json` records what was +actually resolved. +::: + +:::caution pnpm +pnpm 10 and later do not run dependency build scripts unless you allow them, so +the download never happens and the native build fails later on a missing file. +Run `pnpm approve-builds react-native-executorch` once. See +[Troubleshooting](../05-other/02-troubleshooting.md). +::: + ### Backends Hardware backends provide optimized execution kernels for specific processors and platforms. See the [ExecuTorch Backends documentation](https://docs.pytorch.org/executorch/stable/backends-section.html) for details on lowering and delegate compilation: diff --git a/docs/versioned_docs/version-0.10.0/05-other/01-compatibility.mdx b/docs/versioned_docs/version-0.10.0/05-other/01-compatibility.mdx index 31c84ddd62..e99fdbb98a 100644 --- a/docs/versioned_docs/version-0.10.0/05-other/01-compatibility.mdx +++ b/docs/versioned_docs/version-0.10.0/05-other/01-compatibility.mdx @@ -17,7 +17,7 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative React Native ExecuTorch - React Native version + React Native version 0.78 @@ -28,6 +28,7 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative 0.83 0.84 0.85 + 0.86 @@ -41,6 +42,7 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
yes
yes
yes
+
untested†
0.9.x
@@ -52,18 +54,94 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
yes
yes
yes
+
untested†
0.10.x
no
no
no
+
no*
+
no*
yes
yes
yes
yes
+ + + + + +
+ +**\*** `react-native-executorch` 0.10 needs `react-native-worklets` +`>=0.10.0 <0.13.0`, and worklets 0.10 is the first release to serialize an +`ArrayBufferView` natively ([reanimated +#9475](https://github.com/software-mansion/react-native-reanimated/pull/9475)); +older ones rebuild the view over its whole backing buffer, losing `byteOffset` +and `length`. Worklets 0.10 in turn requires React Native 0.83+, which is what +rules out 0.81 and 0.82. + +**†** Not verified. 0.8.x and 0.9.x were released before React Native 0.86 and +are maintained on the `legacy` dist-tag only. + +
+ +## Expo SDK + +`npx expo install` picks the `react-native-worklets` an SDK bundles, which is +older than this library needs on SDK 55 and 56. Both work once you ask for the +versions below explicitly — Reanimated pins worklets exactly, so it moves with +it. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Expo SDKReact NativeBundled workletsreact-native-executorch 0.10.x
54
0.810.5.1
no
55
0.830.7.4
needs explicit versions
56
0.850.8.3
needs explicit versions
57
0.860.10.1
yes
+ +
+ +On SDK 55 and 56, install both: + +```bash +npm install react-native-worklets@^0.10.0 react-native-reanimated@^4.5.0 +``` + +SDK 54 is React Native 0.81, below what worklets 0.10 accepts, so no +combination of versions works there. + +
diff --git a/docs/versioned_docs/version-0.10.0/05-other/02-troubleshooting.md b/docs/versioned_docs/version-0.10.0/05-other/02-troubleshooting.md new file mode 100644 index 0000000000..4a192182ae --- /dev/null +++ b/docs/versioned_docs/version-0.10.0/05-other/02-troubleshooting.md @@ -0,0 +1,155 @@ +--- +title: Troubleshooting +slug: /other/troubleshooting +description: 'Build and install failures caused by a project setup React Native ExecuTorch cannot control, and what to do about each.' +keywords: + [react native executorch, troubleshooting, use_frameworks, pnpm, opencv, cocoapods, simulator] +--- + +Failures that come from how a project is set up rather than from the library +itself. Each one is reproducible against `0.10.0`, so the symptom is quoted +exactly — search this page for the error you got. + +## `Build input files cannot be found` after install + +``` +error: Build input files cannot be found: +'.../XnnpackBackend.xcframework/ios-arm64-simulator/libXnnpackBackend.a' +``` + +The native artifacts are not in the npm tarball. They are downloaded by a +**postinstall** hook, and your package manager skipped it: + +- **pnpm 10 and later block dependency build scripts by default** and print + `Ignored build scripts: react-native-executorch` during install. +- `--ignore-scripts`, and `npm ci --ignore-scripts`, do the same. + +Nothing fails at that point — not even `pod install`, which does not check that +a vendored framework exists — so the error only appears once Xcode goes looking +for the file. Re-run the hook: + +```bash +pnpm approve-builds react-native-executorch # pnpm +npm rebuild react-native-executorch # npm +node node_modules/react-native-executorch/scripts/download-libs.js +``` + +## `Multiple commands produce .../Headers/Types.h` + +An app built with `use_frameworks!` — directly, as Firebase requires, or through +expo-build-properties' `"useFrameworks": "static"` — makes CocoaPods build the +pod as a framework and flat-copy its public headers into one directory. `0.10.0` +publishes every header, and several share a basename, so the build fails while +it is still being planned. + +Force the pod back to a static library in your `Podfile`: + +```ruby +pre_install do |installer| + installer.pod_targets.each do |pod| + if pod.name == 'react-native-executorch' + def pod.build_type + Pod::BuildType.static_library + end + end + end +end +``` + +Expo SDK 55 and later already do this for you: their autolinking downgrades +every pod that vendors an `.xcframework` to a static library, which is why an +Expo app usually never sees this. Setting `buildReactNativeFromSource: true` +turns that off again. + +## `transitive dependencies that include statically linked binaries` + +``` +[!] The 'Pods-YourApp' target has transitive dependencies that include +statically linked binaries: (.../opencv-rne/opencv2.xcframework) +``` + +`use_frameworks!` with no argument means **dynamic** linkage, which CocoaPods +refuses to combine with a statically linked dependency. Ask for static +frameworks instead: + +```ruby +use_frameworks! :linkage => :static +``` + +## `frameworks with conflicting names: opencv2.xcframework` + +Another pod vendors OpenCV under the same framework name — `react-native-fast-opencv` +(via `FastOpenCV-iOS`) is the common one — and CocoaPods installs only one +framework called `opencv2`. On `0.10.0` the two cannot be installed together. + +If you do not use this library's vision tasks, drop its OpenCV: + +```json +{ + "react-native-executorch": { + "backends": ["xnnpack", "coreml", "mlx"], + "libs": ["phonemis"] + } +} +``` + +Re-run your package manager's install afterwards. See +[Native Libraries](../03-core-and-advanced/08-native-libraries.md) for what each +entry covers — leaving `opencv` out disables every computer-vision task. + +## `None of the architectures in ARCHS (x86_64) are valid` + +The library ships `arm64` slices only, and the podspec excludes `x86_64` from +simulator builds. **Intel Macs cannot build for the iOS simulator**, and neither +can an Apple silicon Mac running Xcode under Rosetta, or an Intel macOS CI +image. Use an Apple silicon machine, or a physical device. + +## `The platform of the target ... may not be compatible` + +``` +[!] The platform of the target `YourApp` (iOS 16.4) may not be compatible with +`react-native-executorch (0.10.0)` which has a minimum requirement of iOS 17.0. +``` + +This is a **warning**, so `pod install` still succeeds and the failure surfaces +later. The library needs iOS 17. In an Expo app, set it explicitly — the default +is lower: + +```json +[ + "expo-build-properties", + { "ios": { "deploymentTarget": "17.0" } } +] +``` + +## The `react-native-executorch` config block is ignored + +The postinstall hook reads the block from the directory where the install was +invoked (`INIT_CWD`). In a monorepo that is the **workspace root**, so a block +in `apps/mobile/package.json` is never seen — put it in the root +`package.json` instead. Check `node_modules/react-native-executorch/rne-build-config.json` +after installing to confirm which flags were written. + +## An old Android device or emulator crashes on load + +Two separate causes, and both end in the same runtime failure. + +The shipped `.so` files are built against **API 26**, while `0.10.0` declares +`minSdkVersion 21`, so nothing stops a lower-API build. Raise it in your app's +`android/build.gradle`: + +```groovy +ext { + minSdkVersion = 26 +} +``` + +Native code is also shipped for `arm64-v8a` and `x86_64` only. A build that +produces `armeabi-v7a` or `x86` splits (React Native's default +`reactNativeArchitectures` lists all four) will package those without the +library's `.so`, and loading it fails at runtime on such a device. Restrict the +app to the supported ABIs in `android/gradle.properties`: + +```properties +reactNativeArchitectures=arm64-v8a,x86_64 +``` diff --git a/packages/react-native-executorch/.gitignore b/packages/react-native-executorch/.gitignore index 7a7c31fe50..928071e363 100644 --- a/packages/react-native-executorch/.gitignore +++ b/packages/react-native-executorch/.gitignore @@ -14,3 +14,4 @@ cpp/tests/build/ # Model and tokenizer fixtures downloaded by scripts/fetch-test-fixtures.sh cpp/tests/fixtures/ +include-external-opencv diff --git a/packages/react-native-executorch/__tests__/api/nativeLibsConfig.test.ts b/packages/react-native-executorch/__tests__/api/nativeLibsConfig.test.ts new file mode 100644 index 0000000000..3b8d2dbf8c --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/nativeLibsConfig.test.ts @@ -0,0 +1,149 @@ +/** + * Where `scripts/download-libs.js` looks for the `react-native-executorch` + * block. + * + * The block decides which native artifacts get downloaded, and it is read at + * install time from `INIT_CWD` — the directory the install was invoked in. In a + * workspace that is the repository root, so a block in `apps/mobile/package.json` + * used to be ignored silently: the install succeeded, every backend came down, + * and the app was simply larger than asked for. Worse, an app declaring + * `"libs": []` to dodge an OpenCV conflict got OpenCV anyway. + * + * So the lookup also walks up from the installed package, which lands on the app + * whenever it has its own `node_modules` (pnpm, nohoist). These suites pin both + * paths, and the precedence between them. + */ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const SCRIPT = '../../scripts/download-libs.js'; + +/** npm's own fallback for `INIT_CWD`, spelled the way npm spells it. */ +const LOCAL_PREFIX = 'npm_config_local_prefix'; + +/** + * Loads the script with `INIT_CWD` pointing at `initCwd`. + * + * The variable stays set until `afterEach` restores it: the lookup reads it + * when it is called, not when the module is loaded. + */ +function loadWith(initCwd: string | undefined) { + if (initCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = initCwd; + jest.resetModules(); + return require(SCRIPT); +} + +function writeManifest(dir: string, block?: unknown) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + block === undefined ? { name: 'app' } : { 'name': 'app', 'react-native-executorch': block }, + null, + 2 + ) + ); +} + +describe('native libs user config', () => { + let root: string; + let outerInitCwd: string | undefined; + let outerLocalPrefix: string | undefined; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'rne-config-')); + outerInitCwd = process.env.INIT_CWD; + outerLocalPrefix = process.env[LOCAL_PREFIX]; + // npm_config_local_prefix is the fallback for INIT_CWD; a stray one from the + // outer yarn process would leak into every case. + delete process.env[LOCAL_PREFIX]; + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + if (outerInitCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = outerInitCwd; + if (outerLocalPrefix === undefined) delete process.env[LOCAL_PREFIX]; + else process.env[LOCAL_PREFIX] = outerLocalPrefix; + }); + + it('reads the block from the directory the install ran in', () => { + writeManifest(root, { backends: ['xnnpack'], libs: [] }); + const { findUserConfig } = loadWith(root); + + const { config, manifest } = findUserConfig(); + + expect(config).toEqual({ backends: ['xnnpack'], libs: [] }); + expect(manifest).toBe(join(root, 'package.json')); + }); + + it('prefers the install directory over anything above the package', () => { + writeManifest(root, { backends: ['coreml'] }); + const { findUserConfig } = loadWith(root); + + expect(findUserConfig().config).toEqual({ backends: ['coreml'] }); + }); + + it('reports no block when the manifest has none', () => { + writeManifest(root); + const { findUserConfig } = loadWith(root); + + expect(findUserConfig().config).toBeUndefined(); + }); + + it('falls back to enabling everything when there is no block', () => { + writeManifest(root); + const { readUserConfig, ALL_BACKENDS, ALL_LIBS } = loadWith(root); + + const resolved = readUserConfig(); + + expect(new Set(resolved.backends)).toEqual(new Set(ALL_BACKENDS)); + expect(new Set(resolved.libs)).toEqual(new Set(ALL_LIBS)); + }); + + it('survives an unreadable manifest', () => { + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'package.json'), '{ not json'); + const { findUserConfig } = loadWith(root); + + expect(() => findUserConfig()).not.toThrow(); + }); + + it('expands features into backends and libs', () => { + writeManifest(root, { features: ['textToSpeech'] }); + const { readUserConfig } = loadWith(root); + + const resolved = readUserConfig(); + + expect(resolved.libs).toContain('phonemis'); + expect(resolved.backends).toContain('xnnpack'); + }); + + it('rejects an unknown feature by name', () => { + writeManifest(root, { features: ['telepathy'] }); + const { readUserConfig } = loadWith(root); + + expect(() => readUserConfig()).toThrow(/telepathy/); + }); + + it('carries opencvPod through to the build config the podspec reads', () => { + // Which pod provides opencv2 is the app's call when it already carries an + // OpenCV: the podspec reads this key, and it only reaches the podspec if + // the config script writes it out. + writeManifest(root, { opencvPod: 'FastOpenCV-iOS' }); + const { readUserConfig } = loadWith(root); + + expect(readUserConfig().opencvPod).toBe('FastOpenCV-iOS'); + }); + + it('leaves opencvPod unset when the app does not ask for one', () => { + // Absent means "decide for me", which is what lets the podspec prefer an + // OpenCV the app already has. Writing a default here would freeze that. + writeManifest(root, { backends: ['xnnpack'] }); + const { readUserConfig } = loadWith(root); + + expect(readUserConfig().opencvPod).toBeUndefined(); + }); +}); diff --git a/packages/react-native-executorch/android/build.gradle.kts b/packages/react-native-executorch/android/build.gradle.kts index 01d75e11be..427227a6bf 100644 --- a/packages/react-native-executorch/android/build.gradle.kts +++ b/packages/react-native-executorch/android/build.gradle.kts @@ -53,6 +53,44 @@ fun rneBuildConfig(): Map<*, *> { val rneConfig = rneBuildConfig() fun rneFlag(key: String): String = if (rneConfig[key] != false) "ON" else "OFF" +/** + * The prebuilt ExecuTorch runtime is not in the npm tarball - `package.json` + * excludes it and `scripts/download-libs.js` fetches it from the matching + * GitHub release in a postinstall hook. When a package manager skips that hook + * the install still looks clean and the failure surfaces much later, out of + * CMake, blamed on a missing library rather than on the install. Fail here with + * the fix instead. + */ +fun requireNativeArtifacts() { + val libsDir = file("../third-party/android/libs/executorch") + val present = libsDir.listFiles() + ?.filter { it.isDirectory && it.resolve("libexecutorch.so").exists() } + .orEmpty() + if (present.isNotEmpty()) return + + throw GradleException( + """ + react-native-executorch is missing its native artifacts: + + ${libsDir.absolutePath} + + They are downloaded by this package's postinstall hook, which your + package manager did not run. pnpm 10 and later block dependency build + scripts by default ("Ignored build scripts"), and so do + `--ignore-scripts` and `npm ci --ignore-scripts`. Re-run the hook: + + pnpm approve-builds react-native-executorch # pnpm + npm rebuild react-native-executorch # npm + node node_modules/react-native-executorch/scripts/download-libs.js + + If you provision the libraries yourself, put them under + third-party/android/libs/executorch// before building. + """.trimIndent() + ) +} + +requireNativeArtifacts() + /** * ExecuTorch only supports these ABIs. Honor the app's `reactNativeArchitectures` * (e.g. Expo passes `-PreactNativeArchitectures=arm64-v8a` for device builds) so @@ -70,7 +108,12 @@ android { compileSdk = (getExtOrDefault("compileSdkVersion", 34) as Number).toInt() defaultConfig { - minSdk = (getExtOrDefault("minSdkVersion", 21) as Number).toInt() + // The prebuilt ExecuTorch runtime is compiled against Android API 26 - + // `.note.android.ident` in libexecutorch.so, libxnnpack_executorch_backend.so + // and libvulkan_executorch_backend.so all read 26 - so an app below that + // ships a library its linker is not guaranteed to be able to load. The + // default was 21, which let such a build through to fail on the device. + minSdk = (getExtOrDefault("minSdkVersion", 26) as Number).toInt() targetSdk = (getExtOrDefault("targetSdkVersion", 34) as Number).toInt() consumerProguardFiles("consumer-proguard-rules.pro") diff --git a/packages/react-native-executorch/react-native-executorch.podspec b/packages/react-native-executorch/react-native-executorch.podspec index cb9410c682..8b5d6e6503 100644 --- a/packages/react-native-executorch/react-native-executorch.podspec +++ b/packages/react-native-executorch/react-native-executorch.podspec @@ -1,3 +1,4 @@ +require "fileutils" require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) @@ -21,6 +22,49 @@ else enable_mlx = true end +# The native artifacts are not in the npm tarball - `package.json` excludes +# them and `scripts/download-libs.js` fetches them from the matching GitHub +# release in a postinstall hook. When a package manager skips that hook the +# install still looks clean, and so does `pod install`: CocoaPods never checks +# that a vendored framework exists. The build then fails minutes later with +# +# error: Build input files cannot be found: +# '.../XnnpackBackend.xcframework/ios-arm64-simulator/libXnnpackBackend.a' +# +# which names neither the cause nor the fix. Fail here instead, while the user +# is still looking at the install that caused it. +required_artifacts = { "ExecutorchLib.xcframework" => true } +required_artifacts["XnnpackBackend.xcframework"] = enable_xnnpack +required_artifacts["CoreMLBackend.xcframework"] = enable_coreml +required_artifacts["MLXBackend.xcframework"] = enable_mlx + +missing = required_artifacts + .select { |_, required| required } + .keys + .map { |name| File.join(__dir__, "third-party/ios", name) } + .reject { |path| File.directory?(path) } + +unless missing.empty? + # Pod::Informative renders as a plain `[!]` message rather than a backtrace. + raise(defined?(Pod::Informative) ? Pod::Informative : StandardError, <<~MESSAGE) + react-native-executorch is missing its native artifacts: + + #{missing.map { |path| " #{path}" }.join("\n")} + + They are downloaded by this package's postinstall hook, which your package + manager did not run. pnpm 10 and later block dependency build scripts by + default ("Ignored build scripts"), and so do `--ignore-scripts` and + `npm ci --ignore-scripts`. Re-run the hook, then `pod install` again: + + pnpm approve-builds react-native-executorch # pnpm + npm rebuild react-native-executorch # npm + node node_modules/react-native-executorch/scripts/download-libs.js + + If you provision the libraries yourself, put them under + third-party/ios/ before installing pods. + MESSAGE +end + Pod::Spec.new do |s| s.name = "react-native-executorch" s.version = package["version"] @@ -171,6 +215,63 @@ Pod::Spec.new do |s| 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64', } + # iOS OpenCV is provided by a CocoaPod (not a downloaded tarball), normally + # our own opencv-rne. + # + # An app can already carry OpenCV through another library, and CocoaPods + # refuses to install two vendored frameworks with the same name: + # + # [!] The 'Pods-YourApp' target has frameworks with conflicting names: + # opencv2.xcframework + # + # react-native-fast-opencv is the one this happens with, and wanting both is + # reasonable: our inference API with their image transformations. So when it + # is installed alongside us we depend on the pod it vendors instead of our + # own, which leaves exactly one opencv2 in the project. We only use + # `opencv2/core.hpp` and `opencv2/imgproc.hpp`, so any OpenCV 4.x build + # serves. Set "opencvPod" in the package.json config block to override the + # choice in either direction. + external_opencv = false + if enable_opencv + detected_opencv_pod = + if Dir.exist?(File.join(__dir__, "..", "react-native-fast-opencv")) + "FastOpenCV-iOS" + else + "opencv-rne" + end + opencv_pod = rne_build_config["opencvPod"] || detected_opencv_pod + + if opencv_pod == "opencv-rne" + s.dependency "opencv-rne", "~> 4.11.0" + else + Pod::UI.puts "[react-native-executorch] using #{opencv_pod} for OpenCV " \ + "instead of opencv-rne, so the project holds one opencv2" if defined?(Pod::UI) + s.dependency opencv_pod + external_opencv = true + end + + # Our own OpenCV headers ship under third-party/include and are newer than + # what another OpenCV pod vendors: react-native-fast-opencv carries 4.9, and + # compiling against ours while linking against theirs fails at link time on + # any signature that moved since (cvtColor gained an AlgorithmHint parameter + # in 4.10). Whoever provides the binary has to provide the headers, so with + # an external OpenCV the include root becomes a mirror of ours with opencv2 + # left out, and `#include ` resolves through their framework. + mirror_without_opencv = lambda do + source = File.join(__dir__, "third-party/include") + mirror = File.join(__dir__, "third-party/include-external-opencv") + FileUtils.rm_rf(mirror) + FileUtils.mkdir_p(mirror) + Dir.children(source).each do |entry| + next if entry == "opencv2" + FileUtils.ln_s(File.join(source, entry), File.join(mirror, entry)) + end + "third-party/include-external-opencv" + end + + third_party_include = + external_opencv ? mirror_without_opencv.call : "third-party/include" + s.pod_target_xcconfig = { "USE_HEADERMAP" => "YES", "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", @@ -186,7 +287,7 @@ Pod::Spec.new do |s| # ============================================================================== "\"$(PODS_TARGET_SRCROOT)/legacy/cpp\"", # ============================================================================== - "\"$(PODS_TARGET_SRCROOT)/third-party/include\"", + "\"$(PODS_TARGET_SRCROOT)/#{third_party_include}\"", "\"$(PODS_TARGET_SRCROOT)/third-party/include/cpuinfo\"", "\"$(PODS_TARGET_SRCROOT)/third-party/include/pthreadpool\"", "\"$(PODS_TARGET_SRCROOT)/third-party/include/executorch/extension/llm/tokenizers/include\"", @@ -235,8 +336,7 @@ Pod::Spec.new do |s| # ExecutorchLib goes in vendored_frameworks to avoid duplicate symbol errors. s.ios.vendored_frameworks = ["third-party/ios/ExecutorchLib.xcframework"] - # iOS OpenCV is provided by the opencv-rne CocoaPod (not a downloaded tarball). - s.dependency "opencv-rne", "~> 4.11.0" if enable_opencv + end install_modules_dependencies(s) end diff --git a/packages/react-native-executorch/scripts/download-libs.js b/packages/react-native-executorch/scripts/download-libs.js index aefdd1765e..82527b7d0c 100644 --- a/packages/react-native-executorch/scripts/download-libs.js +++ b/packages/react-native-executorch/scripts/download-libs.js @@ -172,32 +172,67 @@ const FEATURE_MAP = { tokenizer: { backends: [], libs: [] }, }; -function readUserConfig() { - const allOn = () => ({ backends: [...ALL_BACKENDS], libs: [...ALL_LIBS] }); +/** + * The `react-native-executorch` block, and the package.json it came from. + * + * Two places are tried, in order: + * + * 1. INIT_CWD - where the install was invoked. For a single-app project that + * is the app itself. + * 2. Each package.json above this package. In a workspace, `yarn install` is + * run at the root, so INIT_CWD points there and a block in + * `apps/mobile/package.json` would never be seen. When the app keeps its + * own node_modules (pnpm, nohoist), walking up from here lands on the app. + * + * A hoisted workspace resolves to the root either way, which is where the block + * has to live in that layout - there is no way to tell which of several apps a + * root install was meant for. + * @returns The block and the path of the package.json holding it, or both + * `undefined` when no manifest declares one. + */ +function findUserConfig() { + const candidates = []; + const initCwd = process.env.INIT_CWD || process.env.npm_config_local_prefix; + if (initCwd) candidates.push(initCwd); + + // `__dirname` is /node_modules/react-native-executorch/scripts. + let dir = path.resolve(__dirname, '..', '..', '..'); + for (let i = 0; i < 6; i++) { + if (!candidates.includes(dir)) candidates.push(dir); + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } - // npm/yarn set INIT_CWD to the directory where install was invoked (project root) - const projectRoot = process.env.INIT_CWD || process.env.npm_config_local_prefix; - if (!projectRoot) { - console.warn( - '[react-native-executorch] Could not determine project root, enabling all backends + libs.' - ); - return allOn(); + for (const root of candidates) { + const manifest = path.join(root, 'package.json'); + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(manifest, 'utf8')); + } catch { + continue; + } + if (parsed['react-native-executorch'] !== undefined) { + return { config: parsed['react-native-executorch'], manifest }; + } } + return { config: undefined, manifest: undefined }; +} - let rneConfig; - try { - const userPackageJson = JSON.parse( - fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8') - ); - rneConfig = userPackageJson['react-native-executorch']; - } catch { - console.warn( - '[react-native-executorch] Could not read app package.json, enabling all backends + libs.' +function readUserConfig() { + const allOn = () => ({ backends: [...ALL_BACKENDS], libs: [...ALL_LIBS], opencvPod: undefined }); + + const { config: rneConfig, manifest } = findUserConfig(); + + if (rneConfig === undefined) { + console.log( + '[react-native-executorch] No `react-native-executorch` block found; enabling all backends + libs.' ); return allOn(); } - - if (rneConfig === undefined) return allOn(); + // Which manifest won matters when it is not the one the user edited - the + // usual monorepo surprise. + console.log(`[react-native-executorch] Read build config from ${manifest}`); if (rneConfig.extras !== undefined) { throw new Error( @@ -236,10 +271,10 @@ function readUserConfig() { } } - return { backends: [...backends], libs: [...libs] }; + return { backends: [...backends], libs: [...libs], opencvPod: rneConfig.opencvPod }; } -function writeBuildConfig({ backends, libs }) { +function writeBuildConfig({ backends, libs, opencvPod }) { const config = { enableOpencv: libs.includes('opencv'), enablePhonemis: libs.includes('phonemis'), @@ -248,6 +283,10 @@ function writeBuildConfig({ backends, libs }) { enableMlx: backends.includes('mlx'), enableVulkan: backends.includes('vulkan'), }; + // Which pod provides opencv2 on iOS. Only set when the app asks for a + // specific one; otherwise the podspec picks, preferring an OpenCV the app + // already carries so two frameworks named opencv2 never meet. + if (opencvPod !== undefined) config.opencvPod = opencvPod; fs.writeFileSync( path.join(PACKAGE_ROOT, 'rne-build-config.json'), JSON.stringify(config, null, 2) @@ -497,4 +536,4 @@ if (require.main === module) { }); } -module.exports = { ALL_BACKENDS, ALL_LIBS, FEATURE_MAP }; +module.exports = { ALL_BACKENDS, ALL_LIBS, FEATURE_MAP, findUserConfig, readUserConfig };