@callstack/react-native-brownfield 5.2.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/android/build.gradle +1 -0
- package/android/consumer-rules.pro +6 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/BrownfieldPerformance.kt +384 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/BrownfieldPerformanceModule.kt +15 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactDelegateWrapper.kt +8 -5
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +78 -23
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfieldPackage.kt +1 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeFragment.kt +89 -3
- package/android/src/main/java/com/callstack/reactnativebrownfield/constants/ReactNativeFragmentArgNames.kt +2 -0
- package/android/src/test/java/com/callstack/reactnativebrownfield/PerformanceMetricsTest.kt +54 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt +59 -15
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt +6 -8
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +6 -4
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/RNBundleTaskStubs.kt +27 -0
- package/ios/BrownfieldDisplayMetrics.swift +265 -0
- package/ios/BrownfieldPerformanceBridge.h +8 -0
- package/ios/BrownfieldPerformanceBridge.mm +103 -0
- package/ios/Expo/ExpoHostRuntime.swift +58 -14
- package/ios/Expo/ExpoUpdatesStartup.swift +49 -0
- package/ios/JSBundleLoadObserver.swift +56 -19
- package/ios/JSBundleTimingObserver.h +26 -0
- package/ios/JSBundleTimingObserver.m +140 -0
- package/ios/ReactHostPreloading.swift +7 -3
- package/ios/ReactNativeBrownfield.swift +82 -14
- package/ios/ReactNativeBrownfield.xcodeproj/project.pbxproj +16 -0
- package/ios/Vanilla/ReactNativeHostRuntime.swift +1 -1
- package/ios/Views/ReactNativeView.swift +21 -3
- package/ios/Views/ReactNativeViewController.swift +44 -42
- package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
- package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
- package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.js +1 -1
- package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.js.map +1 -1
- package/lib/commonjs/expo-config-plugin/ios/withBrownfieldIos.js +1 -1
- package/lib/commonjs/expo-config-plugin/ios/withBrownfieldIos.js.map +1 -1
- package/lib/commonjs/expo-config-plugin/ios/withIosFrameworkFiles.js +1 -1
- package/lib/commonjs/expo-config-plugin/ios/withIosFrameworkFiles.js.map +1 -1
- package/lib/commonjs/expo-config-plugin/ios/xcodeHelpers.js +2 -2
- package/lib/commonjs/expo-config-plugin/ios/xcodeHelpers.js.map +1 -1
- package/lib/commonjs/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +35 -0
- package/lib/commonjs/expo-config-plugin/template/android/consumer-rules.pro +7 -0
- package/lib/commonjs/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +12 -0
- package/lib/commonjs/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb +5 -0
- package/lib/commonjs/index.js +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/scaffold/index.js +2 -0
- package/lib/commonjs/scaffold/index.js.map +1 -0
- package/lib/commonjs/scaffold/projectFiles.js +2 -0
- package/lib/commonjs/scaffold/projectFiles.js.map +1 -0
- package/lib/commonjs/scaffold/xcode.d.js +2 -0
- package/lib/commonjs/scaffold/xcode.d.js.map +1 -0
- package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
- package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
- package/lib/module/expo-config-plugin/ios/podfileHelpers.js +1 -1
- package/lib/module/expo-config-plugin/ios/podfileHelpers.js.map +1 -1
- package/lib/module/expo-config-plugin/ios/withBrownfieldIos.js +1 -1
- package/lib/module/expo-config-plugin/ios/withBrownfieldIos.js.map +1 -1
- package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js +1 -1
- package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js.map +1 -1
- package/lib/module/expo-config-plugin/ios/xcodeHelpers.js +2 -2
- package/lib/module/expo-config-plugin/ios/xcodeHelpers.js.map +1 -1
- package/lib/module/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +35 -0
- package/lib/module/expo-config-plugin/template/android/consumer-rules.pro +7 -0
- package/lib/module/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +12 -0
- package/lib/module/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb +5 -0
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/scaffold/index.js +2 -0
- package/lib/module/scaffold/index.js.map +1 -0
- package/lib/module/scaffold/projectFiles.js +2 -0
- package/lib/module/scaffold/projectFiles.js.map +1 -0
- package/lib/module/scaffold/xcode.d.js +2 -0
- package/lib/module/scaffold/xcode.d.js.map +1 -0
- package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts +10 -2
- package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/expo-config-plugin/ios/podfileHelpers.d.ts +4 -2
- package/lib/typescript/commonjs/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/expo-config-plugin/ios/withBrownfieldIos.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +11 -2
- package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts +6 -2
- package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +7 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/scaffold/index.d.ts +23 -0
- package/lib/typescript/commonjs/src/scaffold/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts +63 -0
- package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts.map +1 -0
- package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts +10 -2
- package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
- package/lib/typescript/module/src/expo-config-plugin/ios/podfileHelpers.d.ts +4 -2
- package/lib/typescript/module/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
- package/lib/typescript/module/src/expo-config-plugin/ios/withBrownfieldIos.d.ts.map +1 -1
- package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +11 -2
- package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
- package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts +6 -2
- package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +7 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/scaffold/index.d.ts +23 -0
- package/lib/typescript/module/src/scaffold/index.d.ts.map +1 -0
- package/lib/typescript/module/src/scaffold/projectFiles.d.ts +63 -0
- package/lib/typescript/module/src/scaffold/projectFiles.d.ts.map +1 -0
- package/package.json +16 -2
- package/src/expo-config-plugin/android/withAndroidModuleFiles.ts +33 -5
- package/src/expo-config-plugin/ios/podfileHelpers.ts +24 -6
- package/src/expo-config-plugin/ios/withBrownfieldIos.ts +5 -2
- package/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +21 -6
- package/src/expo-config-plugin/ios/xcodeHelpers.ts +77 -5
- package/src/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +35 -0
- package/src/expo-config-plugin/template/android/consumer-rules.pro +7 -0
- package/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +12 -0
- package/src/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb +5 -0
- package/src/index.ts +20 -1
- package/src/scaffold/index.ts +395 -0
- package/src/scaffold/projectFiles.ts +243 -0
- package/src/scaffold/xcode.d.ts +4 -0
|
@@ -11,6 +11,43 @@ import type { ResolvedBrownfieldPluginIosConfig } from '../types';
|
|
|
11
11
|
import { SourceModificationError } from '../errors/SourceModificationError';
|
|
12
12
|
import { getFrameworkSourceFiles } from './withIosFrameworkFiles';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* xcode@3.x addTarget() stores target names/comments quoted (e.g. name =
|
|
16
|
+
* "BrownfieldLib"), and the pbxproj parser keeps the quote characters on
|
|
17
|
+
* re-parse. That makes pbxTargetByName() (comment-based, unquoted) miss
|
|
18
|
+
* targets we previously wrote, so a second run would create a duplicate
|
|
19
|
+
* target. Match by unquoting the stored name as a fallback.
|
|
20
|
+
*/
|
|
21
|
+
function findFrameworkTargetByQuotedName(
|
|
22
|
+
project: XcodeProject,
|
|
23
|
+
frameworkName: string
|
|
24
|
+
): ReturnType<XcodeProject['pbxTargetByName']> {
|
|
25
|
+
const nativeTargets = getRawProjectObjects(project).PBXNativeTarget as
|
|
26
|
+
| Record<string, PbxNativeTarget & { isa?: string }>
|
|
27
|
+
| undefined;
|
|
28
|
+
|
|
29
|
+
for (const [key, target] of Object.entries(nativeTargets ?? {})) {
|
|
30
|
+
if (key.endsWith('_comment') || target?.isa !== 'PBXNativeTarget') {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const name = unquotePbxString(target.name);
|
|
34
|
+
if (name === frameworkName) {
|
|
35
|
+
return target as any;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unquotePbxString(value: string | undefined): string | undefined {
|
|
43
|
+
if (typeof value !== 'string') {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return value.startsWith('"') && value.endsWith('"')
|
|
47
|
+
? value.slice(1, -1)
|
|
48
|
+
: value;
|
|
49
|
+
}
|
|
50
|
+
|
|
14
51
|
/**
|
|
15
52
|
* Adds a new Framework target to the Xcode project for Brownfield packaging
|
|
16
53
|
* @throws If target creation fails
|
|
@@ -20,7 +57,10 @@ import { getFrameworkSourceFiles } from './withIosFrameworkFiles';
|
|
|
20
57
|
export function addFrameworkTarget(
|
|
21
58
|
project: XcodeProject,
|
|
22
59
|
modRequest: ModProps<XcodeProject>,
|
|
23
|
-
options: ResolvedBrownfieldPluginIosConfig
|
|
60
|
+
options: ResolvedBrownfieldPluginIosConfig,
|
|
61
|
+
brownfieldOptions?: {
|
|
62
|
+
useExpoHost?: boolean;
|
|
63
|
+
}
|
|
24
64
|
): {
|
|
25
65
|
frameworkTargetUUID: string;
|
|
26
66
|
targetAlreadyExists: boolean;
|
|
@@ -28,7 +68,9 @@ export function addFrameworkTarget(
|
|
|
28
68
|
const { frameworkName, bundleIdentifier } = options;
|
|
29
69
|
|
|
30
70
|
// check if target already exists
|
|
31
|
-
const existingTarget =
|
|
71
|
+
const existingTarget =
|
|
72
|
+
project.pbxTargetByName(frameworkName) ??
|
|
73
|
+
findFrameworkTargetByQuotedName(project, frameworkName);
|
|
32
74
|
if (existingTarget) {
|
|
33
75
|
Logger.logDebug(
|
|
34
76
|
`Framework target "${frameworkName}" already exists, skipping creation`
|
|
@@ -131,7 +173,7 @@ export function addFrameworkTarget(
|
|
|
131
173
|
});
|
|
132
174
|
|
|
133
175
|
// create the framework group in the project
|
|
134
|
-
const filePaths = getFrameworkSourceFiles(options).map(
|
|
176
|
+
const filePaths = getFrameworkSourceFiles(options, brownfieldOptions).map(
|
|
135
177
|
(file) => file.relativePath
|
|
136
178
|
);
|
|
137
179
|
const groupPath = path.join(modRequest.platformProjectRoot, frameworkName);
|
|
@@ -161,9 +203,12 @@ export function addFrameworkTarget(
|
|
|
161
203
|
export function addSourceFilesBuildPhase(
|
|
162
204
|
project: XcodeProject,
|
|
163
205
|
frameworkTargetUUID: string,
|
|
164
|
-
options: ResolvedBrownfieldPluginIosConfig
|
|
206
|
+
options: ResolvedBrownfieldPluginIosConfig,
|
|
207
|
+
brownfieldOptions?: {
|
|
208
|
+
useExpoHost?: boolean;
|
|
209
|
+
}
|
|
165
210
|
) {
|
|
166
|
-
const filePaths = getFrameworkSourceFiles(options).map(
|
|
211
|
+
const filePaths = getFrameworkSourceFiles(options, brownfieldOptions).map(
|
|
167
212
|
(file) => file.relativePath
|
|
168
213
|
);
|
|
169
214
|
|
|
@@ -171,6 +216,26 @@ export function addSourceFilesBuildPhase(
|
|
|
171
216
|
(filePath) => !filePath.endsWith('.plist')
|
|
172
217
|
);
|
|
173
218
|
|
|
219
|
+
// Idempotency: skip when the target already has a sources phase with this
|
|
220
|
+
// comment, so re-running scaffolding does not duplicate the build phase.
|
|
221
|
+
const nativeTargets = getRawProjectObjects(project).PBXNativeTarget as
|
|
222
|
+
| Record<string, PbxNativeTarget>
|
|
223
|
+
| undefined;
|
|
224
|
+
const targetBuildPhases =
|
|
225
|
+
nativeTargets?.[frameworkTargetUUID]?.buildPhases ?? [];
|
|
226
|
+
const alreadyHasSourcePhase = targetBuildPhases.some((phase) =>
|
|
227
|
+
hasBuildPhaseComment(
|
|
228
|
+
{ comment: typeof phase === 'string' ? undefined : phase.comment },
|
|
229
|
+
options.frameworkName
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
if (alreadyHasSourcePhase) {
|
|
233
|
+
Logger.logDebug(
|
|
234
|
+
`Sources build phase "${options.frameworkName}" already present on target ${frameworkTargetUUID}, skipping`
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
174
239
|
project.addBuildPhase(
|
|
175
240
|
sourceFiles,
|
|
176
241
|
'PBXSourcesBuildPhase',
|
|
@@ -613,6 +678,13 @@ export function copyBundleReactNativePhase(
|
|
|
613
678
|
}
|
|
614
679
|
);
|
|
615
680
|
|
|
681
|
+
// xcode's pbxShellScriptBuildPhaseObj only escapes quotes, leaving real
|
|
682
|
+
// newlines in the shellScript value — that produces an invalid pbxproj
|
|
683
|
+
// once the project is written. Encode both fields the same way the
|
|
684
|
+
// update path above does.
|
|
685
|
+
addedPhase.buildPhase.shellPath = encodePbxString(frameworkShellPath);
|
|
686
|
+
addedPhase.buildPhase.shellScript = encodePbxString(frameworkShellScript);
|
|
687
|
+
|
|
616
688
|
if (existingPhase.showEnvVarsInLog !== undefined) {
|
|
617
689
|
addedPhase.buildPhase.showEnvVarsInLog = existingPhase.showEnvVarsInLog;
|
|
618
690
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import android.content.res.Configuration
|
|
5
|
+
import com.callstack.reactnativebrownfield.OnJSBundleLoaded
|
|
6
|
+
import com.callstack.reactnativebrownfield.ReactNativeBrownfield
|
|
7
|
+
import com.facebook.react.PackageList
|
|
8
|
+
import com.facebook.react.ReactHost
|
|
9
|
+
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
|
10
|
+
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
|
11
|
+
|
|
12
|
+
object ReactNativeHostManager {
|
|
13
|
+
fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) {
|
|
14
|
+
loadReactNative(application)
|
|
15
|
+
|
|
16
|
+
val reactHost: ReactHost by lazy {
|
|
17
|
+
getDefaultReactHost(
|
|
18
|
+
context = application,
|
|
19
|
+
packageList = PackageList(application).packages,
|
|
20
|
+
jsMainModulePath = "index",
|
|
21
|
+
jsBundleAssetPath = "index.android.bundle",
|
|
22
|
+
jsBundleFilePath = null,
|
|
23
|
+
useDevSupport = BuildConfig.DEBUG,
|
|
24
|
+
jsRuntimeFactory = null
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ReactNativeBrownfield.initialize(application, reactHost, onJSBundleLoaded)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fun onConfigurationChanged(application: Application, newConfig: Configuration) {
|
|
32
|
+
// no-op (kept for API symmetry with Expo variant)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-keep interface com.callstack.reactnativebrownfield.OnJSBundleLoaded { *; }
|
|
2
|
+
-keep interface com.callstack.reactnativebrownfield.OnDisplayMetrics { *; }
|
|
3
|
+
-keep class com.callstack.reactnativebrownfield.JSBundleTimings { *; }
|
|
4
|
+
-keep class com.callstack.reactnativebrownfield.BrownfieldDisplayMetrics { *; }
|
|
5
|
+
-keep class com.callstack.reactnativebrownfield.BrownfieldDisplayInterval { *; }
|
|
6
|
+
-keep class com.callstack.reactnativebrownfield.BrownfieldThreadMetrics { *; }
|
|
7
|
+
-keep class com.callstack.reactnativebrownfield.BrownfieldPerformanceModule { *; }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// Re-export the Brownfield runtime so the native host app resolves
|
|
4
|
+
// ReactNativeBrownfield.shared by importing only this framework, matching the
|
|
5
|
+
// manual guide's interface file (docs getting-started/ios.mdx).
|
|
6
|
+
@_exported import ReactBrownfield
|
|
7
|
+
|
|
8
|
+
// Initializes a Bundle instance that points at the framework target.
|
|
9
|
+
public let ReactNativeBundle = Bundle(for: InternalClassForBundle.self)
|
|
10
|
+
|
|
11
|
+
class InternalClassForBundle {}
|
|
12
|
+
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import { Platform } from 'react-native';
|
|
1
|
+
import { NativeModules, Platform } from 'react-native';
|
|
2
2
|
|
|
3
3
|
import ReactNativeBrownfieldModule from './NativeReactNativeBrownfieldModule';
|
|
4
4
|
|
|
5
|
+
// NOTE: the scaffold API is intentionally NOT re-exported here. This module is
|
|
6
|
+
// the package's `react-native`/`source` entry point, so anything reachable from
|
|
7
|
+
// it is traversed by Metro when an app imports the package. The scaffold pulls
|
|
8
|
+
// in node:fs, node:path, xcode and the Community CLI, which cannot bundle.
|
|
9
|
+
// Node-side consumers import it from the dedicated subpath instead:
|
|
10
|
+
// `@callstack/react-native-brownfield/scaffold`.
|
|
11
|
+
|
|
5
12
|
export type { BrownfieldConfig } from '@callstack/brownfield-cli/types';
|
|
6
13
|
|
|
7
14
|
export interface MessageEvent {
|
|
@@ -9,6 +16,18 @@ export interface MessageEvent {
|
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
const ReactNativeBrownfield = {
|
|
19
|
+
/**
|
|
20
|
+
* Finish this presentation's full-display measurement after data and UI are ready.
|
|
21
|
+
* Pass the `brownfieldPresentationID` injected into the root component's props.
|
|
22
|
+
* Native must opt in with `waitForFullDisplay: true`. Repeated/stale IDs are ignored.
|
|
23
|
+
* @platform android, ios
|
|
24
|
+
*/
|
|
25
|
+
markFullyDisplayed: (presentationID: string): void => {
|
|
26
|
+
if (Platform.OS === 'ios' || Platform.OS === 'android') {
|
|
27
|
+
NativeModules.BrownfieldPerformance.markFullyDisplayed(presentationID);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
12
31
|
/**
|
|
13
32
|
* Pop to the native screen.
|
|
14
33
|
* @param animated - Whether to animate the transition (iOS only).
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// Emitted as a triple-slash reference into the generated scaffold .d.ts, so
|
|
2
|
+
// consumers (e.g. @callstack/create-react-native-brownfield) resolve the untyped `xcode`
|
|
3
|
+
// import through this single declaration instead of duplicating xcode.d.ts.
|
|
4
|
+
/// <reference path="./xcode.d.ts" />
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
import type { ModProps, XcodeProject } from '@expo/config-plugins';
|
|
10
|
+
import type { UserConfig } from '@react-native-community/cli-types';
|
|
11
|
+
import cliConfigImport from '@react-native-community/cli-config';
|
|
12
|
+
import xcode from 'xcode';
|
|
13
|
+
|
|
14
|
+
import { Logger } from '../expo-config-plugin/logging';
|
|
15
|
+
import type {
|
|
16
|
+
ResolvedBrownfieldPluginConfigWithAndroid,
|
|
17
|
+
ResolvedBrownfieldPluginConfigWithIos,
|
|
18
|
+
} from '../expo-config-plugin/types';
|
|
19
|
+
import {
|
|
20
|
+
modifyRootBuildGradle,
|
|
21
|
+
modifySettingsGradle,
|
|
22
|
+
} from '../expo-config-plugin/android/utils/gradleHelpers';
|
|
23
|
+
import { createAndroidModule } from '../expo-config-plugin/android/withAndroidModuleFiles';
|
|
24
|
+
import { modifyPodfile } from '../expo-config-plugin/ios/podfileHelpers';
|
|
25
|
+
import {
|
|
26
|
+
addFrameworkTarget,
|
|
27
|
+
addSourceFilesBuildPhase,
|
|
28
|
+
copyBundleReactNativePhase,
|
|
29
|
+
} from '../expo-config-plugin/ios/xcodeHelpers';
|
|
30
|
+
import { createIosFramework } from '../expo-config-plugin/ios/withIosFrameworkFiles';
|
|
31
|
+
import {
|
|
32
|
+
addBrownfieldDependencies,
|
|
33
|
+
addBrownfieldPackageScripts,
|
|
34
|
+
writeBrownfieldFileConfig,
|
|
35
|
+
} from './projectFiles';
|
|
36
|
+
|
|
37
|
+
// ESM/CJS interop shim: `@react-native-community/cli-config` ships CJS whose
|
|
38
|
+
// default export is the function itself, but depending on how this module is
|
|
39
|
+
// loaded (bundler/ESM interop) the import may be the module namespace object
|
|
40
|
+
// with the function on `.default`. Unwrap to the callable either way —
|
|
41
|
+
// do not remove, otherwise the scaffold crashes with "cliConfig is not a function".
|
|
42
|
+
const cliConfig: typeof cliConfigImport =
|
|
43
|
+
typeof cliConfigImport === 'function'
|
|
44
|
+
? cliConfigImport
|
|
45
|
+
: (cliConfigImport as any).default;
|
|
46
|
+
|
|
47
|
+
export type BrownfieldScaffoldOptions = {
|
|
48
|
+
/**
|
|
49
|
+
* React Native project root directory (contains package.json).
|
|
50
|
+
* Defaults to current working directory.
|
|
51
|
+
*/
|
|
52
|
+
projectRoot?: string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* iOS framework target name (also framework directory name).
|
|
56
|
+
* Defaults to "BrownfieldLib".
|
|
57
|
+
*/
|
|
58
|
+
iosFrameworkName?: string;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Android library module folder / Gradle module name.
|
|
62
|
+
* Defaults to "brownfieldlib".
|
|
63
|
+
*/
|
|
64
|
+
androidModuleName?: string;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Enables verbose logging.
|
|
68
|
+
*/
|
|
69
|
+
debug?: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function findProjectRoot(startDir: string): string {
|
|
73
|
+
let currentDir = startDir;
|
|
74
|
+
while (true) {
|
|
75
|
+
if (fs.existsSync(path.join(currentDir, 'package.json'))) {
|
|
76
|
+
return currentDir;
|
|
77
|
+
}
|
|
78
|
+
const parent = path.dirname(currentDir);
|
|
79
|
+
if (parent === currentDir) break;
|
|
80
|
+
currentDir = parent;
|
|
81
|
+
}
|
|
82
|
+
throw new Error('Could not find project root (no package.json found)');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveUserConfig(projectRoot: string): UserConfig {
|
|
86
|
+
// Do not pass selectedPlatform: it restricts detection to a single platform
|
|
87
|
+
// and would leave project.android undefined.
|
|
88
|
+
return cliConfig({
|
|
89
|
+
projectRoot,
|
|
90
|
+
}) as UserConfig;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readFileIfExists(filePath: string): string | null {
|
|
94
|
+
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeFileIfChanged(filePath: string, next: string) {
|
|
98
|
+
const prev = readFileIfExists(filePath);
|
|
99
|
+
if (prev === next) return;
|
|
100
|
+
fs.writeFileSync(filePath, next, 'utf8');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function firstXcodeprojPath(iosDir: string): string {
|
|
104
|
+
const entries = fs.readdirSync(iosDir, { withFileTypes: true });
|
|
105
|
+
const xcodeproj = entries.find(
|
|
106
|
+
(e) => e.isDirectory() && e.name.endsWith('.xcodeproj')
|
|
107
|
+
);
|
|
108
|
+
if (!xcodeproj) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Could not find an .xcodeproj under ${iosDir}. Did you run iOS project generation?`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return path.join(iosDir, xcodeproj.name);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function unquote(value: string): string {
|
|
117
|
+
return value.replace(/^"+|"+$/g, '');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveIosAppBundleId(pbxproj: any): string | null {
|
|
121
|
+
const nativeTargets = pbxproj.pbxNativeTargetSection?.() ?? {};
|
|
122
|
+
const configLists = pbxproj.pbxXCConfigurationList?.() ?? {};
|
|
123
|
+
const buildConfigs = pbxproj.pbxXCBuildConfigurationSection?.() ?? {};
|
|
124
|
+
|
|
125
|
+
for (const [key, target] of Object.entries<any>(nativeTargets)) {
|
|
126
|
+
if (key.endsWith('_comment')) continue;
|
|
127
|
+
// heuristic: application targets usually have productType including "application"
|
|
128
|
+
if (
|
|
129
|
+
typeof target?.productType === 'string' &&
|
|
130
|
+
!target.productType.includes('application')
|
|
131
|
+
) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const configListId = target?.buildConfigurationList;
|
|
136
|
+
const configList = configLists?.[configListId];
|
|
137
|
+
const debugConfigId = configList?.buildConfigurations?.find?.(
|
|
138
|
+
(c: any) => c?.comment === 'Debug'
|
|
139
|
+
)?.value;
|
|
140
|
+
const debugConfig = debugConfigId ? buildConfigs?.[debugConfigId] : null;
|
|
141
|
+
const bundleId = debugConfig?.buildSettings?.PRODUCT_BUNDLE_IDENTIFIER;
|
|
142
|
+
if (typeof bundleId === 'string' && bundleId.length > 0) {
|
|
143
|
+
return unquote(bundleId);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveReactNativeVersion(projectRoot: string): string {
|
|
151
|
+
// The shipped build is CommonJS, where bare `require` works. If this module
|
|
152
|
+
// is ever loaded as true ESM, bare `require` is undefined; fall back to an
|
|
153
|
+
// explicit `createRequire`. Anchored at cwd (not `import.meta.url`) because
|
|
154
|
+
// Babel keeps `import.meta` verbatim in the CJS output, which is a parse
|
|
155
|
+
// SyntaxError there.
|
|
156
|
+
const localRequire: NodeRequire =
|
|
157
|
+
typeof require === 'undefined'
|
|
158
|
+
? createRequire(path.join(process.cwd(), 'noop.js'))
|
|
159
|
+
: require;
|
|
160
|
+
const rnPkgPath = localRequire.resolve('react-native/package.json', {
|
|
161
|
+
paths: [projectRoot],
|
|
162
|
+
});
|
|
163
|
+
const rnPkg = localRequire(rnPkgPath);
|
|
164
|
+
if (!rnPkg?.version) {
|
|
165
|
+
throw new Error('Could not resolve react-native version from package.json');
|
|
166
|
+
}
|
|
167
|
+
return rnPkg.version;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function scaffoldBrownfieldInRncCliProject(
|
|
171
|
+
options: BrownfieldScaffoldOptions = {}
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
const projectRoot = findProjectRoot(
|
|
174
|
+
path.resolve(options.projectRoot ?? process.cwd())
|
|
175
|
+
);
|
|
176
|
+
Logger.setIsDebug(options.debug ?? false);
|
|
177
|
+
|
|
178
|
+
const userConfig = resolveUserConfig(projectRoot);
|
|
179
|
+
const android = userConfig.project.android;
|
|
180
|
+
const ios = userConfig.project.ios;
|
|
181
|
+
|
|
182
|
+
if (!android) {
|
|
183
|
+
throw new Error('Android project not found.');
|
|
184
|
+
}
|
|
185
|
+
if (!ios) {
|
|
186
|
+
throw new Error('iOS project not found.');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const androidDir = path.isAbsolute(android.sourceDir)
|
|
190
|
+
? android.sourceDir
|
|
191
|
+
: path.join(projectRoot, android.sourceDir || 'android');
|
|
192
|
+
// Honour the CLI-resolved iOS source directory (symmetry with Android
|
|
193
|
+
// above): a project that sets project.ios.sourceDir to a custom relative
|
|
194
|
+
// path makes the Community CLI resolve it to an absolute directory, and
|
|
195
|
+
// hard-coding <root>/ios would then miss the .xcodeproj or mutate the
|
|
196
|
+
// wrong directory. Note: an absolute sourceDir authored by the user is
|
|
197
|
+
// already rejected upstream (cli-config-apple joins it onto the project
|
|
198
|
+
// root), so the isAbsolute branch mainly guards the resolved value.
|
|
199
|
+
const iosSourceDir = ios.sourceDir;
|
|
200
|
+
const iosDir = path.isAbsolute(iosSourceDir)
|
|
201
|
+
? iosSourceDir
|
|
202
|
+
: path.join(projectRoot, iosSourceDir || 'ios');
|
|
203
|
+
|
|
204
|
+
const rnVersion = resolveReactNativeVersion(projectRoot);
|
|
205
|
+
|
|
206
|
+
const iosFrameworkName = options.iosFrameworkName ?? 'BrownfieldLib';
|
|
207
|
+
const androidModuleName = options.androidModuleName ?? 'brownfieldlib';
|
|
208
|
+
const androidPackageName = android.packageName ?? android.applicationId;
|
|
209
|
+
if (!androidPackageName) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
'Could not resolve Android package name from React Native CLI config.'
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Tracks the current mutation phase so a mid-way failure can name the step
|
|
216
|
+
// that failed and point users at fix-forward guidance (see catch below).
|
|
217
|
+
let currentStep = 'preconditions';
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
currentStep = 'android-gradle';
|
|
221
|
+
// --- Android: root build.gradle + settings.gradle ---
|
|
222
|
+
const rootBuildGradlePath = path.join(androidDir, 'build.gradle');
|
|
223
|
+
const rootBuildGradle = readFileIfExists(rootBuildGradlePath);
|
|
224
|
+
if (!rootBuildGradle) {
|
|
225
|
+
throw new Error(`Missing ${rootBuildGradlePath}`);
|
|
226
|
+
}
|
|
227
|
+
writeFileIfChanged(
|
|
228
|
+
rootBuildGradlePath,
|
|
229
|
+
modifyRootBuildGradle(rootBuildGradle)
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const settingsGradlePath = path.join(androidDir, 'settings.gradle');
|
|
233
|
+
const settingsGradle = readFileIfExists(settingsGradlePath);
|
|
234
|
+
if (!settingsGradle) {
|
|
235
|
+
throw new Error(`Missing ${settingsGradlePath}`);
|
|
236
|
+
}
|
|
237
|
+
writeFileIfChanged(
|
|
238
|
+
settingsGradlePath,
|
|
239
|
+
modifySettingsGradle(settingsGradle, androidModuleName)
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const resolvedAndroidConfig: ResolvedBrownfieldPluginConfigWithAndroid = {
|
|
243
|
+
android: {
|
|
244
|
+
moduleName: androidModuleName,
|
|
245
|
+
packageName: androidPackageName,
|
|
246
|
+
// minSdk has no rootProject fallback in the shared build.gradle.kts
|
|
247
|
+
// template (it renders a literal), so keep the Expo plugin default.
|
|
248
|
+
// RN CLI rootProject.ext.minSdkVersion is 24 on current templates
|
|
249
|
+
// anyway; the generated build.gradle.kts stays readable.
|
|
250
|
+
minSdkVersion: 24,
|
|
251
|
+
// compile/target SDK are intentionally not hardcoded: leaving them
|
|
252
|
+
// unset makes the shared template fall back to
|
|
253
|
+
// resolveRootProjectInt("compileSdkVersion"/"targetSdkVersion"), i.e.
|
|
254
|
+
// the app's own rootProject.ext values (RN 0.87 needs compileSdk 37+;
|
|
255
|
+
// AGP 9 rejects library `targetSdk` entirely — see
|
|
256
|
+
// renderTargetSdkBlock). Proven against a real RN 0.87.1 app.
|
|
257
|
+
targetSdkVersion: undefined,
|
|
258
|
+
compileSdkVersion: undefined,
|
|
259
|
+
groupId: androidPackageName,
|
|
260
|
+
artifactId: androidModuleName,
|
|
261
|
+
version: '0.0.1-SNAPSHOT',
|
|
262
|
+
// Fields added to the resolved config after the original draft was
|
|
263
|
+
// written (BGP local-plugin wiring + flavor dimensions, #448/#458).
|
|
264
|
+
// Defaults match the Expo plugin defaults for RN CLI projects.
|
|
265
|
+
useLocalGradlePlugin: false,
|
|
266
|
+
useLocalMaven: false,
|
|
267
|
+
missingDimensionStrategies: [],
|
|
268
|
+
},
|
|
269
|
+
ios: null,
|
|
270
|
+
debug: options.debug ?? false,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
currentStep = 'android-module';
|
|
274
|
+
createAndroidModule({
|
|
275
|
+
androidDir,
|
|
276
|
+
config: resolvedAndroidConfig,
|
|
277
|
+
rnVersion,
|
|
278
|
+
// 'vanilla' = non-Expo host (same axis as `useExpoHost: false` below).
|
|
279
|
+
templateVariant: 'vanilla',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// --- iOS: xcodeproj + Podfile + framework source files ---
|
|
283
|
+
currentStep = 'ios-project';
|
|
284
|
+
const xcodeprojPath = firstXcodeprojPath(iosDir);
|
|
285
|
+
const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
|
|
286
|
+
if (!fs.existsSync(pbxprojPath)) {
|
|
287
|
+
throw new Error(`Missing ${pbxprojPath}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const project = xcode.project(pbxprojPath);
|
|
291
|
+
project.parseSync();
|
|
292
|
+
|
|
293
|
+
const appBundleId = resolveIosAppBundleId(project);
|
|
294
|
+
const brownfieldBundleId = appBundleId
|
|
295
|
+
? `${appBundleId}.brownfield`
|
|
296
|
+
: `com.brownfield.${iosFrameworkName.toLowerCase()}`;
|
|
297
|
+
|
|
298
|
+
const resolvedIosConfig: ResolvedBrownfieldPluginConfigWithIos = {
|
|
299
|
+
ios: {
|
|
300
|
+
frameworkName: iosFrameworkName,
|
|
301
|
+
bundleIdentifier: brownfieldBundleId,
|
|
302
|
+
buildSettings: {},
|
|
303
|
+
// Defaults aligned with the Expo plugin defaults (Expo's fallback
|
|
304
|
+
// when no deployment target can be derived; see
|
|
305
|
+
// resolveFrameworkDeploymentTarget). RN CLI config does not expose
|
|
306
|
+
// an iOS deployment target directly.
|
|
307
|
+
// TODO: derive from the Podfile `platform :ios` line or RN's
|
|
308
|
+
// min_ios_version_supported, or expose a flag.
|
|
309
|
+
deploymentTarget: '15.0',
|
|
310
|
+
// Standard CFBundleShortVersionString for the generated framework
|
|
311
|
+
// (matches the Expo plugin default); unrelated to package versioning.
|
|
312
|
+
frameworkVersion: '1',
|
|
313
|
+
},
|
|
314
|
+
android: null,
|
|
315
|
+
debug: options.debug ?? false,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Contract: downstream helpers (xcodeHelpers.addFrameworkTarget /
|
|
319
|
+
// resolveAppTargetName) only read `platformProjectRoot` (framework group
|
|
320
|
+
// location) and `projectName` (app-target-name fallback), but the shared
|
|
321
|
+
// Expo-path signatures require a full ModProps, so we provide every field
|
|
322
|
+
// with scaffold-appropriate values (no mods are run here, so `introspect`
|
|
323
|
+
// is false and `nextMod` is omitted).
|
|
324
|
+
const modRequest: ModProps<XcodeProject> = {
|
|
325
|
+
projectRoot,
|
|
326
|
+
platformProjectRoot: iosDir,
|
|
327
|
+
modName: 'react-native-brownfield-scaffold',
|
|
328
|
+
platform: 'ios',
|
|
329
|
+
introspect: false,
|
|
330
|
+
projectName: path.basename(xcodeprojPath, '.xcodeproj'),
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const { frameworkTargetUUID } = addFrameworkTarget(
|
|
334
|
+
project,
|
|
335
|
+
modRequest,
|
|
336
|
+
resolvedIosConfig.ios,
|
|
337
|
+
{ useExpoHost: false }
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
copyBundleReactNativePhase(project, frameworkTargetUUID);
|
|
341
|
+
addSourceFilesBuildPhase(
|
|
342
|
+
project,
|
|
343
|
+
frameworkTargetUUID,
|
|
344
|
+
resolvedIosConfig.ios,
|
|
345
|
+
{ useExpoHost: false }
|
|
346
|
+
);
|
|
347
|
+
// xcode@3.x writeSync() returns the serialized project but does not write it.
|
|
348
|
+
// This is the riskiest write: a failure here leaves a partially mutated
|
|
349
|
+
// pbxproj on disk (the in-memory changes are lost, not half-written).
|
|
350
|
+
fs.writeFileSync(pbxprojPath, project.writeSync());
|
|
351
|
+
|
|
352
|
+
currentStep = 'ios-podfile';
|
|
353
|
+
const podfilePath = path.join(iosDir, 'Podfile');
|
|
354
|
+
const podfile = readFileIfExists(podfilePath);
|
|
355
|
+
if (!podfile) {
|
|
356
|
+
throw new Error(`Missing ${podfilePath}`);
|
|
357
|
+
}
|
|
358
|
+
writeFileIfChanged(podfilePath, modifyPodfile(podfile, iosFrameworkName));
|
|
359
|
+
|
|
360
|
+
currentStep = 'ios-sources';
|
|
361
|
+
createIosFramework(iosDir, resolvedIosConfig, { useExpoHost: false });
|
|
362
|
+
|
|
363
|
+
// --- App package wiring: Brownfield deps + package:* scripts ---
|
|
364
|
+
// The scaffolded Kotlin host imports com.callstack.reactnativebrownfield.*,
|
|
365
|
+
// so the JS/runtime package must be an app dependency for native builds to
|
|
366
|
+
// resolve it. Idempotent: existing version specs are left untouched.
|
|
367
|
+
currentStep = 'package-dependencies';
|
|
368
|
+
addBrownfieldDependencies(projectRoot);
|
|
369
|
+
|
|
370
|
+
currentStep = 'package-scripts';
|
|
371
|
+
addBrownfieldPackageScripts(projectRoot);
|
|
372
|
+
|
|
373
|
+
// --- brownfield.config.json: packaging settings for `brownfield package:*` ---
|
|
374
|
+
// Deterministic content derived from the scaffolded names, so re-runs
|
|
375
|
+
// produce identical bytes (overwrite == no-op).
|
|
376
|
+
currentStep = 'brownfield-config';
|
|
377
|
+
writeBrownfieldFileConfig(projectRoot, {
|
|
378
|
+
iosFrameworkName,
|
|
379
|
+
androidModuleName,
|
|
380
|
+
});
|
|
381
|
+
} catch (error) {
|
|
382
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
383
|
+
Logger.logInfo(
|
|
384
|
+
'Scaffolding failed mid-way; fix-forward: re-running the CLI is idempotent for completed steps (gradle settings, Podfile, xcodeproj target detection, package.json deps/scripts, brownfield.config.json) — inspect partial changes with git diff.'
|
|
385
|
+
);
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Brownfield scaffolding failed during step "${currentStep}": ${message}`,
|
|
388
|
+
{
|
|
389
|
+
cause: error,
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
Logger.logInfo('Brownfield scaffolding complete.');
|
|
395
|
+
}
|