diff --git a/.changeset/wicked-clocks-visit.md b/.changeset/wicked-clocks-visit.md
new file mode 100644
index 00000000..97902069
--- /dev/null
+++ b/.changeset/wicked-clocks-visit.md
@@ -0,0 +1,5 @@
+---
+'@callstack/react-native-brownfield': patch
+---
+
+fix: autodetect iOS app target name for Expo pre-55 patch script phase
diff --git a/docs/docs/docs/getting-started/expo.mdx b/docs/docs/docs/getting-started/expo.mdx
index 8666e27a..e815e102 100644
--- a/docs/docs/docs/getting-started/expo.mdx
+++ b/docs/docs/docs/getting-started/expo.mdx
@@ -5,6 +5,7 @@ import { PackageManagerTabs } from '@theme';
This guide walks you through packaging your Expo React Native app as an **AAR** or **XCFramework** and integrating it into your native **Android** or **iOS** application.
## Prerequisites
+
- Install the `@callstack/react-native-brownfield` package from the quick start [section](/docs/getting-started/quick-start#installation)
## Configuration
@@ -13,9 +14,7 @@ This guide walks you through packaging your Expo React Native app as an **AAR**
```json
{
- "plugins": [
- "@callstack/react-native-brownfield",
- ]
+ "plugins": ["@callstack/react-native-brownfield"]
}
```
@@ -55,7 +54,7 @@ This should only take a few minutes.
> That is all from the AAR steps. We can now consume the AAR inside a native Android App.
-### AAR: Present RN UI
+### AAR: Present RN UI
1. Call the `ReactNativeHostManager.initialize` in your Activity or Application:
@@ -80,6 +79,7 @@ override fun onConfigurationChanged(newConfig: Configuration) {
```
3. Use either of the following APIs to present the UI:
+
- ReactNativeFragment
- See the example [here](https://github.com/callstack/react-native-brownfield/blob/41c81059acda8b134b6fea6bbbcf918c20d16552/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt#L133)
- ReactNativeFragment.createReactNativeFragment
@@ -90,7 +90,7 @@ override fun onConfigurationChanged(newConfig: Configuration) {
4. Build and install the android application 🚀
-
+
## iOS Integration
@@ -109,7 +109,7 @@ This should only take a few minutes.
##### Pre-Requisites
- Follow the step for adding the frameworks to your iOS App - [here](/docs/getting-started/ios#6-add-the-framework-to-your-ios-app)
-
+
1. Call the following functions from your Application Entry point:
@@ -117,7 +117,7 @@ This should only take a few minutes.
@main
struct IosApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
-
+
init() {
ReactNativeBrownfield.shared.bundle = ReactNativeBundle
ReactNativeBrownfield.shared.startReactNative {
@@ -125,7 +125,7 @@ struct IosApp: App {
}
ReactNativeBrownfield.shared.ensureExpoModulesProvider()
}
-
+
var body: some Scene {
WindowGroup {
ContentView()
@@ -139,7 +139,7 @@ struct IosApp: App {
```swift
class AppDelegate: NSObject, UIApplicationDelegate {
var window: UIWindow?
-
+
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
@@ -160,7 +160,7 @@ ReactNativeView(moduleName: "ExpoRNApp")
4. Build and install the iOS application 🚀
-
+
## Plugin Options
@@ -201,6 +201,14 @@ You can pass plugin options through the second item in the `plugins` tuple in `a
- `frameworkVersion` (`string`, default: `"1"`)
- Framework version used for Apple build settings (must be an integer or floating point value, for example `"1"` or `"2.1"`).
+> The plugin will determine the application target automatically. The auto-detection path works as follows:
+>
+> 1. _Common for all cases_: scan the iOS targets for ones of type `com.apple.product-type.application`
+> 2. Use the first matching strategy:
+> - CNG-derived name from mod compiler (`modRequest.projectName`) - only if it exists in the filtered application-type list of Xcode project targets
+> - Unambiguous first application-type target - if there is exactly one
+> - PBX "first native target" fallback - last resort fallback that selects the first native target of any type
+
### Android
- `moduleName` (`string`, default: `"brownfieldlib"`)
diff --git a/packages/react-native-brownfield/package.json b/packages/react-native-brownfield/package.json
index 77fe3c08..1e56e4b7 100644
--- a/packages/react-native-brownfield/package.json
+++ b/packages/react-native-brownfield/package.json
@@ -82,8 +82,7 @@
"access": "public"
},
"peerDependencies": {
- "react": "*",
- "react-native": "*"
+ "@expo/config-plugins": "^54.0.4"
},
"dependencies": {
"@callstack/brownfield-cli": "workspace:^"
@@ -92,6 +91,7 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
+ "@expo/config-plugins": "^54.0.4",
"@react-native/babel-preset": "0.82.1",
"@types/jest": "^30.0.0",
"@types/react": "^19.1.1",
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts
index 61653c4c..894af550 100644
--- a/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts
+++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts
@@ -32,7 +32,6 @@ export const withBrownfieldIos: ConfigPlugin<
? parseInt(config.sdkVersion.split('.')[0], 10)
: -1;
const isExpoPre55 = expoMajor < 55;
-
// Step 1: modify the Xcode project to add framework target &
config = withXcodeProject(config, (xcodeConfig) => {
const { modResults: project, modRequest } = xcodeConfig;
@@ -60,7 +59,7 @@ export const withBrownfieldIos: ConfigPlugin<
`Adding ExpoModulesProvider patch phase for Expo SDK ${config.sdkVersion}`
);
- addExpoPre55ShellPatchScriptPhase(project, {
+ addExpoPre55ShellPatchScriptPhase(modRequest, project, {
frameworkName: props.ios.frameworkName,
frameworkTargetUUID: frameworkTargetUUID,
});
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts
index 58579077..82f577eb 100644
--- a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts
+++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts
@@ -1,6 +1,10 @@
import path from 'node:path';
-import type { ModProps, XcodeProject } from '@expo/config-plugins';
+import {
+ type ModProps,
+ type XcodeProject,
+ IOSConfig,
+} from '@expo/config-plugins';
import { Logger } from '../logging';
import type { ResolvedBrownfieldPluginIosConfig } from '../types';
@@ -286,7 +290,66 @@ export function copyBundleReactNativePhase(
}
}
+function resolveAppTargetName(
+ project: XcodeProject,
+ modRequest: ModProps
+): string | null {
+ const appTargets = IOSConfig.Target.getNativeTargets(project)
+ .map(([, target]) => {
+ if (
+ !IOSConfig.Target.isTargetOfType(
+ target,
+ IOSConfig.Target.TargetType.APPLICATION
+ )
+ ) {
+ return null;
+ }
+
+ const name = IOSConfig.XcodeUtils.unquote(target.name ?? '').trim();
+
+ return name ?? null;
+ })
+ .filter((name): name is string => !!name);
+
+ // 1) Unambiguous first application-type target
+ if (appTargets.length === 1) {
+ return appTargets[0];
+ } else {
+ Logger.logWarning(
+ 'Multiple application targets found in the Xcode project. Falling back to the CNG-derived name from mod compiler.'
+ );
+ }
+
+ // 2) CNG-derived name from mod compiler (`modRequest.projectName`) - only if it exists in the filtered application-type list of Xcode project targets
+ const cngDerivedProjectName = modRequest.projectName;
+ if (cngDerivedProjectName && appTargets.includes(cngDerivedProjectName)) {
+ return cngDerivedProjectName;
+ } else {
+ Logger.logWarning(
+ 'CNG-derived name from mod compiler is not set or is not an application target. Falling back to the unfiltered-type target name.'
+ );
+ }
+
+ // 3) PBX "first native target" fallback
+ try {
+ const [, firstAppTarget] = IOSConfig.Target.findFirstNativeTarget(project);
+ const name = IOSConfig.XcodeUtils.unquote(firstAppTarget.name ?? '').trim();
+ return name || null;
+ } catch {
+ Logger.logWarning(
+ 'No first native target of any type found in the Xcode project. This was the last resort fallback.'
+ );
+ }
+
+ Logger.logError(
+ `Could not determine the iOS app target name from the Xcode project. Please adjust your Xcode project to have exactly one application target.`
+ );
+
+ return null;
+}
+
export function addExpoPre55ShellPatchScriptPhase(
+ modRequest: ModProps,
project: XcodeProject,
{
frameworkName,
@@ -296,6 +359,16 @@ export function addExpoPre55ShellPatchScriptPhase(
frameworkTargetUUID: string;
}
) {
+ const resolvedAppTargetName = resolveAppTargetName(project, modRequest);
+
+ Logger.logInfo(`Resolved iOS app target name: ${resolvedAppTargetName}`);
+
+ if (!resolvedAppTargetName) {
+ throw new SourceModificationError(
+ `Could not determine the iOS app target name from the Xcode project.`
+ );
+ }
+
project.addBuildPhase(
[
// no associated files
@@ -306,6 +379,7 @@ export function addExpoPre55ShellPatchScriptPhase(
{
shellPath: '/bin/sh',
shellScript: renderTemplate('ios', 'patchExpoPre55.sh', {
+ '{{APP_TARGET_NAME}}': resolvedAppTargetName,
'{{FRAMEWORK_NAME}}': frameworkName,
}),
}
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/patchExpoPre55.sh b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/patchExpoPre55.sh
index 80d9c30b..11fbd2bf 100644
--- a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/patchExpoPre55.sh
+++ b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/patchExpoPre55.sh
@@ -1,7 +1,7 @@
# Patch by @hurali97, source: https://github.com/callstackincubator/rock/issues/492#issuecomment-3225109837
# Applicable only to Expo SDK versions prior to 55, which made ExpoModulesProvider internal by default: https://github.com/expo/expo/pull/42317
# Path to ExpoModulesProvider.swift
-FILE="${SRCROOT}/Pods/Target Support Files/Pods-ExpoApp-{{FRAMEWORK_NAME}}/ExpoModulesProvider.swift"
+FILE="${SRCROOT}/Pods/Target Support Files/Pods-{{APP_TARGET_NAME}}-{{FRAMEWORK_NAME}}/ExpoModulesProvider.swift"
if [ -f "$FILE" ]; then
echo "Patching $FILE to hide Expo from public interface"
diff --git a/yarn.lock b/yarn.lock
index 4e0b91b3..5d6a6047 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2420,6 +2420,7 @@ __metadata:
"@babel/preset-env": "npm:^7.25.3"
"@babel/runtime": "npm:^7.25.0"
"@callstack/brownfield-cli": "workspace:^"
+ "@expo/config-plugins": "npm:^54.0.4"
"@react-native/babel-preset": "npm:0.82.1"
"@types/jest": "npm:^30.0.0"
"@types/react": "npm:^19.1.1"
@@ -2432,8 +2433,7 @@ __metadata:
react-native-builder-bob: "npm:^0.40.17"
typescript: "npm:5.9.3"
peerDependencies:
- react: "*"
- react-native: "*"
+ "@expo/config-plugins": ^54.0.4
bin:
brownfield: lib/commonjs/scripts/brownfield.js
languageName: unknown