diff --git a/.changeset/tender-poems-rhyme.md b/.changeset/tender-poems-rhyme.md new file mode 100644 index 00000000..e22d1899 --- /dev/null +++ b/.changeset/tender-poems-rhyme.md @@ -0,0 +1,5 @@ +--- +'@callstack/react-native-brownfield': minor +--- + +Add an opt-in iOS Debug mode for loading the embedded JavaScript bundle with `preferEmbeddedBundleInDebug`, fix `bundleURLOverride` fallback behavior when the override returns `nil`, and add native bundle-resolution tests. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95e3bb7e..9ec99b11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,25 @@ jobs: run: | yarn workspace @callstack/react-native-brownfield brownfield --version + ios-native-tests: + name: iOS native tests + runs-on: macos-26 + needs: build-lint + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Setup + uses: ./.github/actions/setup + + - name: Run Swift bundle resolver tests + run: | + cd packages/react-native-brownfield/ios/swiftpm + mkdir -p "$RUNNER_TEMP/swift-home" "$RUNNER_TEMP/swift-cache/clang" "$RUNNER_TEMP/swift-cache/swiftpm" + HOME="$RUNNER_TEMP/swift-home" \ + CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/swift-cache/clang" \ + swift test --scratch-path "$RUNNER_TEMP/swift-cache/swiftpm" + android-androidapp-expo: name: Android road test (RNApp & AndroidApp - Expo ${{ matrix.version }}) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e7169098..df918d12 100644 --- a/.gitignore +++ b/.gitignore @@ -82,8 +82,10 @@ secring.gpg # Typescript **/*.tsbuildinfo +packages/react-native-brownfield/ios/.build/ +packages/react-native-brownfield/ios/swiftpm/.build/ # skillgym .skillgym-results/ -.cursor \ No newline at end of file +.cursor diff --git a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift index 09295b42..2286a3ca 100644 --- a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift +++ b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift @@ -79,6 +79,7 @@ struct BrownfieldAppleApp: App { init() { ReactNativeBrownfield.shared.bundle = ReactNativeBundle + ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true ReactNativeBrownfield.shared.startReactNative { print("React Native has been loaded") } diff --git a/apps/AppleApp/Brownfield Apple App/components/ContentView.swift b/apps/AppleApp/Brownfield Apple App/components/ContentView.swift index 05f0766b..067f44d3 100644 --- a/apps/AppleApp/Brownfield Apple App/components/ContentView.swift +++ b/apps/AppleApp/Brownfield Apple App/components/ContentView.swift @@ -19,12 +19,12 @@ struct ContentView: View { NavigationView { VStack(spacing: 16) { - GreetingCard(name: "iOS Expo") + GreetingCard(name: "iOS Vanilla") MessagesView() ReactNativeView( - moduleName: "main", + moduleName: "RNApp", initialProperties: [ "nativeOsVersionLabel": "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" diff --git a/apps/RNApp/ios/Podfile.lock b/apps/RNApp/ios/Podfile.lock index 391b3e8d..710db466 100644 --- a/apps/RNApp/ios/Podfile.lock +++ b/apps/RNApp/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - BrownfieldNavigation (3.6.0): + - BrownfieldNavigation (3.6.1): - boost - DoubleConversion - fast_float @@ -28,7 +28,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Brownie (3.6.0): + - Brownie (3.6.1): - boost - DoubleConversion - fast_float @@ -2461,7 +2461,7 @@ PODS: - SocketRocket - ReactAppDependencyProvider (0.85.0): - ReactCodegen - - ReactBrownfield (3.6.0): + - ReactBrownfield (3.6.1): - boost - DoubleConversion - fast_float @@ -2898,14 +2898,14 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - BrownfieldNavigation: 0a4abcd0295639640d0222ac5c47ab63d94983c8 - Brownie: c75e781646955724c3b385e1a53704cc06491bf0 + BrownfieldNavigation: 814180cb04b5cef3ecc4da5f7c91e83f8b5e4d24 + Brownie: cd20e6cc71ab50983941cdb371c22a8f55d3e232 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: dfb9ab6ee2eac316f7869edf6ec27b9e872329f0 fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: e56ede4028c4b7418e6b1195a36b1656bb35e225 - hermes-engine: 133acc7688f66a6db232bff7de874c7129b01e1e + hermes-engine: 4d7529a5cdee0d79a872e3f164da84c1ec01f559 RCT-Folly: 36c4f904fb6cd0219dcb76b94e9502d2a72fab0b RCTDeprecation: df7412cdad525035c3adeb14c1dc35b344e98187 RCTRequired: 28a4bf1ef190650fcd6973d8a6a8f8beb30ef807 @@ -2974,7 +2974,7 @@ SPEC CHECKSUMS: React-utils: f2dc3878565c3cc54bdf7f65a106efaf93f189a6 React-webperformancenativemodule: 214e42892a044b865f73ad4f88cac6979c27aa76 ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2 - ReactBrownfield: 9e36bd174c53254c7a283a6305a4b26589e75f97 + ReactBrownfield: 4ff15e707d420a617cb8ad1a225f03a88f0baf3f ReactCodegen: 6ddd8f44847646a047320a22f5ddb10b27a515c9 ReactCommon: 6a42764f1136fb9ac210e05e88a0733a00ee23d3 RNScreens: e902eba58a27d3ad399a495d578e8aba3ea0f490 diff --git a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx index 19f6a905..e3b5128b 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx @@ -33,6 +33,7 @@ A singleton that keeps an instance of `ReactNativeBrownfield` object. | `entryFile` | `NSString` | `index` | Path to JavaScript entry file in development. | | `bundlePath` | `NSString` | `main.jsbundle` | Path to JavaScript bundle file. | | `bundle` | `NSBundle` | `Bundle.main` | Bundle instance to lookup the JavaScript bundle resource. | +| `preferEmbeddedBundleInDebug` | `BOOL` | `NO` | Prefer the embedded JavaScript bundle instead of Metro when the framework is built in Debug. | | `bundleURLOverride` | `NSURL *(^)(void)` | `nil` | Dynamic bundle URL provider called on every bundle load. When set, overrides default bundle load behavior. | --- diff --git a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx index 01c356c3..62a536e6 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx @@ -33,6 +33,7 @@ ReactNativeBrownfield.shared | `entryFile` | `String` | `index` | Path to JavaScript entry file in development. | | `bundlePath` | `String` | `main.jsbundle` | Path to JavaScript bundle file. | | `bundle` | `Bundle` | `Bundle.main` | Bundle instance to lookup the JavaScript bundle resource. | +| `preferEmbeddedBundleInDebug` | `Bool` | `false` | Prefer the embedded JavaScript bundle instead of Metro when the framework is built in Debug. | | `bundleURLOverride` | `(() -> URL?)?` | `nil` | Dynamic bundle URL provider called on every bundle load. When set, overrides default behavior. | --- diff --git a/docs/docs/docs/getting-started/expo.mdx b/docs/docs/docs/getting-started/expo.mdx index adb55595..f946fe50 100644 --- a/docs/docs/docs/getting-started/expo.mdx +++ b/docs/docs/docs/getting-started/expo.mdx @@ -124,6 +124,8 @@ struct IosApp: App { init() { ReactNativeBrownfield.shared.bundle = ReactNativeBundle + // Optional: use the packaged bundle even when the consumed framework is built in Debug. + // ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true ReactNativeBrownfield.shared.startReactNative { print("React Native has been loaded") } diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index b4f353e9..83f2b9e4 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -171,6 +171,16 @@ When running in **Debug**, React Native Brownfield expects a JS dev server runni npx react-native start ``` +## Embedded bundle in Development + +If you want to run a **Debug-built** framework without Metro, enable the bundled bundle explicitly before calling `startReactNative`: + +```swift +ReactNativeBrownfield.shared.bundle = ReactNativeBundle +ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true +ReactNativeBrownfield.shared.startReactNative() +``` + ### Release Configuration In **Release**, the JS bundle is loaded directly from the XCFramework - no dev server needed. diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index 501f5856..53fe869b 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -26,6 +26,8 @@ import { import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; import { stripFrameworkBinary } from '../utils/stripFrameworkBinary.js'; +import { copyDebugBundleToSimulatorSlice } from '../utils/copyDebugBundleToSimulatorSlice.js'; +import { resolvePackagedFrameworkName } from '../utils/resolvePackagedFrameworkName.js'; export const packageIosCommand = curryOptions( new Command('package:ios').description('Build iOS XCFramework'), @@ -96,6 +98,49 @@ export const packageIosCommand = curryOptions( platformConfig ); + const productsPath = path.join(options.buildFolder, 'Build', 'Products'); + const { frameworkName, resolution, candidates } = resolvePackagedFrameworkName( + { + explicitScheme: options.scheme, + productsPath, + configuration, + } + ); + + if (frameworkName) { + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration, + frameworkName, + }); + + if (configuration.includes('Debug')) { + await mergeFrameworks({ + sourceDir: userConfig.project.ios.sourceDir, + frameworkPaths: [ + path.join( + productsPath, + `${configuration}-iphoneos`, + `${frameworkName}.framework` + ), + path.join( + productsPath, + `${configuration}-iphonesimulator`, + `${frameworkName}.framework` + ), + ], + outputPath: path.join(packageDir, `${frameworkName}.xcframework`), + }); + } + } else if (configuration.includes('Debug')) { + const debugResolutionMessage = + resolution === 'ambiguous' + ? `Skipping Debug simulator JS bundle copy: found multiple bundled framework candidates (${candidates?.join(', ') ?? 'none'}); pass --scheme explicitly` + : 'Skipping Debug simulator JS bundle copy: could not resolve the packaged framework output automatically; pass --scheme explicitly'; + + logger.warn(debugResolutionMessage); + } + const reactBrownfieldXcframeworkPath = path.join( packageDir, 'ReactBrownfield.xcframework' @@ -108,7 +153,6 @@ export const packageIosCommand = curryOptions( } if (hasBrownie) { - const productsPath = path.join(options.buildFolder, 'Build', 'Products'); const brownieOutputPath = path.join(packageDir, 'Brownie.xcframework'); await mergeFrameworks({ @@ -141,7 +185,6 @@ export const packageIosCommand = curryOptions( } if (hasNavigation) { - const productsPath = path.join(options.buildFolder, 'Build', 'Products'); const brownfieldNavigationOutputPath = path.join(packageDir, 'BrownfieldNavigation.xcframework'); await mergeFrameworks({ diff --git a/packages/cli/src/brownfield/utils/__tests__/copy-debug-bundle-to-simulator-slice.test.ts b/packages/cli/src/brownfield/utils/__tests__/copy-debug-bundle-to-simulator-slice.test.ts new file mode 100644 index 00000000..035ff1b9 --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/copy-debug-bundle-to-simulator-slice.test.ts @@ -0,0 +1,177 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import * as rockTools from '@rock-js/tools'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { copyDebugBundleToSimulatorSlice } from '../copyDebugBundleToSimulatorSlice.js'; + +vi.mock('@rock-js/tools', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logger: { + ...actual.logger, + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + success: vi.fn(), + debug: vi.fn(), + }, + }; +}); + +const mockLoggerWarn = rockTools.logger.warn as ReturnType; +const mockLoggerSuccess = rockTools.logger.success as ReturnType; + +function createFramework(pathname: string) { + fs.mkdirSync(pathname, { recursive: true }); + fs.writeFileSync(path.join(pathname, 'BrownfieldLib'), 'fake binary'); +} + +describe('copyDebugBundleToSimulatorSlice', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copy-debug-bundle-test-')); + vi.clearAllMocks(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('copies main.jsbundle into the Debug simulator slice when it is missing', () => { + const productsPath = path.join(tempDir, 'Build', 'Products'); + + const deviceFrameworkPath = path.join( + productsPath, + 'Debug-iphoneos', + 'BrownfieldLib.framework' + ); + const simulatorFrameworkPath = path.join( + productsPath, + 'Debug-iphonesimulator', + 'BrownfieldLib.framework' + ); + + createFramework(deviceFrameworkPath); + createFramework(simulatorFrameworkPath); + + fs.writeFileSync( + path.join(deviceFrameworkPath, 'main.jsbundle'), + 'debug bundled output' + ); + + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration: 'Debug', + frameworkName: 'BrownfieldLib', + }); + + const simulatorBundlePath = path.join( + simulatorFrameworkPath, + 'main.jsbundle' + ); + + expect(fs.readFileSync(simulatorBundlePath, 'utf8')).toBe( + 'debug bundled output' + ); + expect(mockLoggerSuccess).toHaveBeenCalledWith( + expect.stringContaining('Copied Debug JS bundle to simulator slice') + ); + }); + + it('does nothing for non-Debug configurations', () => { + const productsPath = path.join(tempDir, 'Build', 'Products'); + + const deviceFrameworkPath = path.join( + productsPath, + 'Release-iphoneos', + 'BrownfieldLib.framework' + ); + const simulatorFrameworkPath = path.join( + productsPath, + 'Release-iphonesimulator', + 'BrownfieldLib.framework' + ); + + createFramework(deviceFrameworkPath); + createFramework(simulatorFrameworkPath); + fs.writeFileSync( + path.join(deviceFrameworkPath, 'main.jsbundle'), + 'release bundle' + ); + + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration: 'Release', + frameworkName: 'BrownfieldLib', + }); + + expect( + fs.existsSync(path.join(simulatorFrameworkPath, 'main.jsbundle')) + ).toBe(false); + expect(mockLoggerSuccess).not.toHaveBeenCalled(); + }); + + it('warns and skips when the device bundle is missing', () => { + const productsPath = path.join(tempDir, 'Build', 'Products'); + + const simulatorFrameworkPath = path.join( + productsPath, + 'Debug-iphonesimulator', + 'BrownfieldLib.framework' + ); + + createFramework(simulatorFrameworkPath); + + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration: 'Debug', + frameworkName: 'BrownfieldLib', + }); + + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.stringContaining('Skipping simulator JS bundle copy') + ); + }); + + it('overwrites an existing simulator bundle with the Debug device bundle', () => { + const productsPath = path.join(tempDir, 'Build', 'Products'); + + const deviceFrameworkPath = path.join( + productsPath, + 'Debug-iphoneos', + 'BrownfieldLib.framework' + ); + const simulatorFrameworkPath = path.join( + productsPath, + 'Debug-iphonesimulator', + 'BrownfieldLib.framework' + ); + + createFramework(deviceFrameworkPath); + createFramework(simulatorFrameworkPath); + + fs.writeFileSync( + path.join(deviceFrameworkPath, 'main.jsbundle'), + 'fresh debug bundle' + ); + fs.writeFileSync( + path.join(simulatorFrameworkPath, 'main.jsbundle'), + 'stale simulator bundle' + ); + + copyDebugBundleToSimulatorSlice({ + productsPath, + configuration: 'Debug', + frameworkName: 'BrownfieldLib', + }); + + expect( + fs.readFileSync(path.join(simulatorFrameworkPath, 'main.jsbundle'), 'utf8') + ).toBe('fresh debug bundle'); + }); +}); diff --git a/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts b/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts new file mode 100644 index 00000000..a9baea3f --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { resolvePackagedFrameworkName } from '../resolvePackagedFrameworkName.js'; + +function createFramework(baseDir: string, frameworkName: string, withBundle = false) { + const frameworkPath = path.join(baseDir, `${frameworkName}.framework`); + fs.mkdirSync(frameworkPath, { recursive: true }); + fs.writeFileSync(path.join(frameworkPath, frameworkName), 'fake binary'); + + if (withBundle) { + fs.writeFileSync(path.join(frameworkPath, 'main.jsbundle'), 'bundled js'); + } +} + +describe('resolvePackagedFrameworkName', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-packaged-framework-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('prefers the explicit scheme when provided', () => { + expect( + resolvePackagedFrameworkName({ + explicitScheme: 'BrownfieldLib', + productsPath: tempDir, + configuration: 'Debug', + }) + ).toEqual({ + frameworkName: 'BrownfieldLib', + resolution: 'explicit', + }); + }); + + it('resolves the packaged framework automatically from the device build output', () => { + const deviceProductsPath = path.join(tempDir, 'Debug-iphoneos'); + createFramework(deviceProductsPath, 'BrownfieldLib', true); + createFramework(path.join(deviceProductsPath, 'Brownie'), 'Brownie'); + createFramework(path.join(deviceProductsPath, 'BrownfieldNavigation'), 'BrownfieldNavigation'); + + expect( + resolvePackagedFrameworkName({ + productsPath: tempDir, + configuration: 'Debug', + }) + ).toEqual({ + frameworkName: 'BrownfieldLib', + resolution: 'detected', + }); + }); + + it('reports when the framework cannot be resolved automatically', () => { + const deviceProductsPath = path.join(tempDir, 'Debug-iphoneos'); + createFramework(path.join(deviceProductsPath, 'Brownie'), 'Brownie'); + + expect( + resolvePackagedFrameworkName({ + productsPath: tempDir, + configuration: 'Debug', + }) + ).toEqual({ + frameworkName: null, + resolution: 'not_found', + candidates: [], + }); + }); + + it('reports ambiguity when multiple frameworks contain a packaged bundle', () => { + const deviceProductsPath = path.join(tempDir, 'Debug-iphoneos'); + createFramework(deviceProductsPath, 'BrownfieldLib', true); + createFramework(deviceProductsPath, 'OtherFramework', true); + + expect( + resolvePackagedFrameworkName({ + productsPath: tempDir, + configuration: 'Debug', + }) + ).toEqual({ + frameworkName: null, + resolution: 'ambiguous', + candidates: ['BrownfieldLib', 'OtherFramework'], + }); + }); +}); diff --git a/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts b/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts new file mode 100644 index 00000000..bf9c719d --- /dev/null +++ b/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { colorLink, logger, relativeToCwd } from '@rock-js/tools'; + +interface CopyDebugBundleToSimulatorSliceOptions { + productsPath: string; + configuration: string; + frameworkName: string; +} + +export function copyDebugBundleToSimulatorSlice({ + productsPath, + configuration, + frameworkName, +}: CopyDebugBundleToSimulatorSliceOptions) { + if (!configuration.includes('Debug')) { + return; + } + + const deviceBundlePath = path.join( + productsPath, + `${configuration}-iphoneos`, + `${frameworkName}.framework`, + 'main.jsbundle' + ); + + const simulatorFrameworkPath = path.join( + productsPath, + `${configuration}-iphonesimulator`, + `${frameworkName}.framework` + ); + + const simulatorBundlePath = path.join( + simulatorFrameworkPath, + 'main.jsbundle' + ); + + if (!fs.existsSync(deviceBundlePath)) { + logger.warn( + `Skipping simulator JS bundle copy: missing ${relativeToCwd(deviceBundlePath)}` + ); + return; + } + + if (!fs.existsSync(simulatorFrameworkPath)) { + logger.warn( + `Skipping simulator JS bundle copy: missing ${relativeToCwd(simulatorFrameworkPath)}` + ); + return; + } + + fs.copyFileSync(deviceBundlePath, simulatorBundlePath); + + logger.success( + `Copied Debug JS bundle to simulator slice at ${colorLink(relativeToCwd(simulatorBundlePath))}` + ); +} diff --git a/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts b/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts new file mode 100644 index 00000000..4213742f --- /dev/null +++ b/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +type Resolution = 'explicit' | 'detected' | 'not_found' | 'ambiguous'; + +export interface ResolvePackagedFrameworkNameResult { + frameworkName: string | null; + resolution: Resolution; + candidates?: string[]; +} + +interface ResolvePackagedFrameworkNameOptions { + explicitScheme?: string; + productsPath: string; + configuration: string; +} + +function collectFrameworkCandidates(configurationProductsPath: string): string[] { + if (!fs.existsSync(configurationProductsPath)) { + return []; + } + + const discoveredFrameworks = new Set(); + + for (const entry of fs.readdirSync(configurationProductsPath, { withFileTypes: true })) { + const entryPath = path.join(configurationProductsPath, entry.name); + + if (entry.isDirectory() && entry.name.endsWith('.framework')) { + const frameworkName = path.basename(entry.name, '.framework'); + const bundlePath = path.join(entryPath, 'main.jsbundle'); + + if (fs.existsSync(bundlePath)) { + discoveredFrameworks.add(frameworkName); + } + + continue; + } + + if (!entry.isDirectory()) { + continue; + } + + for (const nestedEntry of fs.readdirSync(entryPath, { withFileTypes: true })) { + if (!nestedEntry.isDirectory() || !nestedEntry.name.endsWith('.framework')) { + continue; + } + + const frameworkName = path.basename(nestedEntry.name, '.framework'); + const bundlePath = path.join(entryPath, nestedEntry.name, 'main.jsbundle'); + + if (fs.existsSync(bundlePath)) { + discoveredFrameworks.add(frameworkName); + } + } + } + + return [...discoveredFrameworks].sort(); +} + +export function resolvePackagedFrameworkName({ + explicitScheme, + productsPath, + configuration, +}: ResolvePackagedFrameworkNameOptions): ResolvePackagedFrameworkNameResult { + if (explicitScheme) { + return { + frameworkName: explicitScheme, + resolution: 'explicit', + }; + } + + const configurationProductsPath = path.join( + productsPath, + `${configuration}-iphoneos` + ); + const candidates = collectFrameworkCandidates(configurationProductsPath); + + if (candidates.length === 1) { + return { + frameworkName: candidates[0] ?? null, + resolution: 'detected', + }; + } + + if (candidates.length === 0) { + return { + frameworkName: null, + resolution: 'not_found', + candidates, + }; + } + + return { + frameworkName: null, + resolution: 'ambiguous', + candidates, + }; +} diff --git a/packages/react-native-brownfield/ReactBrownfield.podspec b/packages/react-native-brownfield/ReactBrownfield.podspec index c7411598..4a0ebfe7 100644 --- a/packages/react-native-brownfield/ReactBrownfield.podspec +++ b/packages/react-native-brownfield/ReactBrownfield.podspec @@ -15,6 +15,7 @@ Pod::Spec.new do |spec| spec.module_name = "ReactBrownfield" spec.source = { :git => "git@github.com:callstack/react-native-brownfield.git", :tag => "#{spec.version}" } spec.source_files = "ios/**/*.{h,m,mm,swift}" + spec.exclude_files = "ios/swiftpm/Package.swift", "ios/swiftpm/Tests/**/*" spec.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'OTHER_SWIFT_FLAGS' => "-enable-experimental-feature AccessLevelOnImport" diff --git a/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.h b/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.h new file mode 100644 index 00000000..247c2ce3 --- /dev/null +++ b/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.h @@ -0,0 +1,11 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface BrownfieldDevLoadingViewBridge : NSObject + ++ (void)setEnabled:(BOOL)enabled; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.m b/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.m new file mode 100644 index 00000000..568622fe --- /dev/null +++ b/packages/react-native-brownfield/ios/BrownfieldDevLoadingViewBridge.m @@ -0,0 +1,12 @@ +#import "BrownfieldDevLoadingViewBridge.h" + +#import + +@implementation BrownfieldDevLoadingViewBridge + ++ (void)setEnabled:(BOOL)enabled +{ + RCTDevLoadingViewSetEnabled(enabled); +} + +@end diff --git a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift index 3567a3be..5c913bca 100644 --- a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift @@ -17,6 +17,16 @@ final class ExpoHostRuntime { private var reactNativeFactory: RCTReactNativeFactory? private var expoDelegate: ExpoAppDelegate? + private func configureDevLoadingView(with bundleURL: URL? = nil) { + #if DEBUG + let resolvedBundleURL = bundleURL ?? delegate.bundleURL() + let shouldDisableDevLoadingView = + preferEmbeddedBundleInDebug && (resolvedBundleURL?.isFileURL ?? false) + + BrownfieldDevLoadingViewBridge.setEnabled(!shouldDisableDevLoadingView) + #endif + } + /** * Starts React Native with default parameters. */ @@ -31,6 +41,8 @@ final class ExpoHostRuntime { public func startReactNative(onBundleLoaded: (() -> Void)?) { guard reactNativeFactory == nil else { return } + configureDevLoadingView() + let appDelegate = ExpoAppDelegate() delegate.dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = ExpoReactNativeFactory(delegate: delegate) @@ -88,6 +100,16 @@ final class ExpoHostRuntime { delegate.bundle = bundle } } + + /** + * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Default value: false + */ + public var preferEmbeddedBundleInDebug: Bool = false { + didSet { + delegate.preferEmbeddedBundleInDebug = preferEmbeddedBundleInDebug + } + } /** * Dynamic bundle URL provider called on every bundle load. * When set, this overrides the default bundleURL() behavior in the delegate. @@ -144,6 +166,7 @@ final class ExpoHostRuntime { launchOptions: [AnyHashable: Any]? ) -> UIView? { let bundleURL = delegate.bundleURL() + configureDevLoadingView(with: bundleURL) // below: https://github.com/expo/expo/commit/2013760c46cde1404872d181a691da72fbf207a4 // has moved the recreateRootView method to ExpoReactNativeFactory @@ -169,6 +192,7 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { var entryFile = ".expo/.virtual-metro-entry" var bundlePath = "main.jsbundle" var bundle = Bundle.main + var preferEmbeddedBundleInDebug = false var bundleURLOverride: (() -> URL?)? = nil override func sourceURL(for bridge: RCTBridge) -> URL? { @@ -177,27 +201,40 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { } override func bundleURL() -> URL? { - if let bundleURLProvider = bundleURLOverride { return bundleURLProvider() } -#if DEBUG - return RCTBundleURLProvider.sharedSettings().jsBundleURL( - forBundleRoot: entryFile) -#else - #if canImport(EXUpdates) - if AppController.isInitialized(), - let launchAssetURL = AppController.sharedInstance.launchAssetUrl() { - return launchAssetURL - } - #endif do { - let (resourceName, fileExtension) = try BrownfieldBundlePathResolver.resourceComponents( - from: bundlePath + #if DEBUG + let isDebug = true + #else + let isDebug = false + #endif + + if let overriddenURL = bundleURLOverride?() { + return overriddenURL + } + + #if canImport(EXUpdates) + if !isDebug, + AppController.isInitialized(), + let launchAssetURL = AppController.sharedInstance.launchAssetUrl() { + return launchAssetURL + } + #endif + + return try BrownfieldBundleURLResolver.resolve( + isDebug: isDebug, + preferEmbeddedBundleInDebug: preferEmbeddedBundleInDebug, + bundlePath: bundlePath, + bundle: bundle, + bundleURLOverride: nil, + metroURL: { + RCTBundleURLProvider.sharedSettings().jsBundleURL( + forBundleRoot: entryFile) + } ) - return bundle.url(forResource: resourceName, withExtension: fileExtension) } catch { assertionFailure("Invalid bundlePath '\(bundlePath)': \(error)") return nil } -#endif } } #endif diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift index 0b0e4579..bd11c281 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -55,6 +55,20 @@ internal import Expo } } + /** + * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Default value: false + */ + @objc public var preferEmbeddedBundleInDebug: Bool = false { + didSet { + #if canImport(Expo) + ExpoHostRuntime.shared.preferEmbeddedBundleInDebug = preferEmbeddedBundleInDebug + #else + ReactNativeHostRuntime.shared.preferEmbeddedBundleInDebug = preferEmbeddedBundleInDebug + #endif + } + } + /** * Dynamic bundle URL provider called on every bundle load. * When set, this overrides the default bundleURL() behavior in the delegate. diff --git a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift index 3e019a91..140b503b 100644 --- a/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift @@ -7,6 +7,7 @@ class ReactNativeBrownfieldDelegate: RCTDefaultReactNativeFactoryDelegate { var entryFile = "index" var bundlePath = "main.jsbundle" var bundle = Bundle.main + var preferEmbeddedBundleInDebug = false var bundleURLOverride: (() -> URL?)? = nil // MARK: - RCTReactNativeFactoryDelegate Methods @@ -15,23 +16,27 @@ class ReactNativeBrownfieldDelegate: RCTDefaultReactNativeFactoryDelegate { } public override func bundleURL() -> URL? { - if let bundleURLProvider = bundleURLOverride { - return bundleURLProvider() - } - -#if DEBUG - return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: entryFile) -#else do { - let (resourceName, fileExtension) = try BrownfieldBundlePathResolver.resourceComponents( - from: bundlePath + #if DEBUG + let isDebug = true + #else + let isDebug = false + #endif + + return try BrownfieldBundleURLResolver.resolve( + isDebug: isDebug, + preferEmbeddedBundleInDebug: preferEmbeddedBundleInDebug, + bundlePath: bundlePath, + bundle: bundle, + bundleURLOverride: bundleURLOverride, + metroURL: { + RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: entryFile) + } ) - return bundle.url(forResource: resourceName, withExtension: fileExtension) } catch { assertionFailure("Invalid bundlePath '\(bundlePath)': \(error)") return nil } -#endif } } @@ -40,6 +45,15 @@ final class ReactNativeHostRuntime { private let jsBundleLoadObserver = JSBundleLoadObserver() private var delegate = ReactNativeBrownfieldDelegate() + private func configureDevLoadingView() { + #if DEBUG + let shouldDisableDevLoadingView = + preferEmbeddedBundleInDebug && (delegate.bundleURL()?.isFileURL ?? false) + + BrownfieldDevLoadingViewBridge.setEnabled(!shouldDisableDevLoadingView) + #endif + } + /** * Path to JavaScript root. * Default value: "index" @@ -70,6 +84,16 @@ final class ReactNativeHostRuntime { } } + /** + * Prefer the embedded JavaScript bundle instead of Metro when this framework is built in Debug. + * Default value: false + */ + public var preferEmbeddedBundleInDebug: Bool = false { + didSet { + delegate.preferEmbeddedBundleInDebug = preferEmbeddedBundleInDebug + } + } + /** * Dynamic bundle URL provider called on every bundle load. * When set, this overrides the default bundleURL() behavior in the delegate. @@ -112,7 +136,9 @@ final class ReactNativeHostRuntime { initialProps: [AnyHashable: Any]?, launchOptions: [AnyHashable: Any]? = nil ) -> UIView? { - reactNativeFactory?.rootViewFactory.view( + configureDevLoadingView() + + return reactNativeFactory?.rootViewFactory.view( withModuleName: moduleName, initialProperties: initialProps, launchOptions: launchOptions @@ -163,6 +189,8 @@ final class ReactNativeHostRuntime { public func startReactNative(onBundleLoaded: (() -> Void)?) { guard reactNativeFactory == nil else { return } + configureDevLoadingView() + delegate.dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = RCTReactNativeFactory(delegate: delegate) diff --git a/packages/react-native-brownfield/ios/swiftpm/Package.swift b/packages/react-native-brownfield/ios/swiftpm/Package.swift new file mode 100644 index 00000000..3a8fe1c3 --- /dev/null +++ b/packages/react-native-brownfield/ios/swiftpm/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "BrownfieldBundleSupport", + platforms: [ + .macOS(.v13), + ], + products: [ + .library( + name: "BrownfieldBundleSupport", + targets: ["BrownfieldBundleSupport"] + ), + ], + targets: [ + .target( + name: "BrownfieldBundleSupport", + path: "Sources/BrownfieldBundleSupport" + ), + .testTarget( + name: "BrownfieldBundleSupportTests", + dependencies: ["BrownfieldBundleSupport"], + path: "Tests/BrownfieldBundleSupportTests" + ), + ] +) diff --git a/packages/react-native-brownfield/ios/BrownfieldBundlePathResolver.swift b/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundlePathResolver.swift similarity index 100% rename from packages/react-native-brownfield/ios/BrownfieldBundlePathResolver.swift rename to packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundlePathResolver.swift diff --git a/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift b/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift new file mode 100644 index 00000000..ce744833 --- /dev/null +++ b/packages/react-native-brownfield/ios/swiftpm/Sources/BrownfieldBundleSupport/BrownfieldBundleURLResolver.swift @@ -0,0 +1,28 @@ +import Foundation + +final class BrownfieldBundleURLResolver { + private init() {} + + static func resolve( + isDebug: Bool, + preferEmbeddedBundleInDebug: Bool, + bundlePath: String, + bundle: Bundle, + bundleURLOverride: (() -> URL?)?, + metroURL: () -> URL? + ) throws -> URL? { + if let overriddenURL = bundleURLOverride?() { + return overriddenURL + } + + if isDebug && !preferEmbeddedBundleInDebug { + return metroURL() + } + + let (resourceName, fileExtension) = try BrownfieldBundlePathResolver.resourceComponents( + from: bundlePath + ) + + return bundle.url(forResource: resourceName, withExtension: fileExtension) + } +} diff --git a/packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift b/packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift new file mode 100644 index 00000000..6e4a52f1 --- /dev/null +++ b/packages/react-native-brownfield/ios/swiftpm/Tests/BrownfieldBundleSupportTests/BrownfieldBundleURLResolverTests.swift @@ -0,0 +1,156 @@ +import XCTest +@testable import BrownfieldBundleSupport + +final class BrownfieldBundleURLResolverTests: XCTestCase { + func test_debugResolutionPrefersBundledResourceWhenEnabled() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let bundle = try makeFixtureBundle() + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: true, + preferEmbeddedBundleInDebug: true, + bundlePath: "main.jsbundle", + bundle: bundle, + bundleURLOverride: nil, + metroURL: { metroURL } + ) + + XCTAssertNotNil(resolvedURL) + XCTAssertEqual(resolvedURL?.lastPathComponent, "main.jsbundle") + XCTAssertNotEqual(resolvedURL, metroURL) + } + + func test_debugResolutionUsesMetroByDefault() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let bundle = try makeFixtureBundle() + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: true, + preferEmbeddedBundleInDebug: false, + bundlePath: "main.jsbundle", + bundle: bundle, + bundleURLOverride: nil, + metroURL: { metroURL } + ) + + XCTAssertEqual(resolvedURL, metroURL) + } + + func test_releaseResolutionUsesBundledResource() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let bundle = try makeFixtureBundle() + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: false, + preferEmbeddedBundleInDebug: false, + bundlePath: "main.jsbundle", + bundle: bundle, + bundleURLOverride: nil, + metroURL: { metroURL } + ) + + XCTAssertNotNil(resolvedURL) + XCTAssertEqual(resolvedURL?.lastPathComponent, "main.jsbundle") + XCTAssertNotEqual(resolvedURL, metroURL) + } + + func test_bundleURLOverrideTakesPrecedenceWhenItReturnsAURL() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let overrideURL = URL(string: "https://example.com/custom.bundle")! + let bundle = try makeFixtureBundle() + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: true, + preferEmbeddedBundleInDebug: false, + bundlePath: "main.jsbundle", + bundle: bundle, + bundleURLOverride: { overrideURL }, + metroURL: { metroURL } + ) + + XCTAssertEqual(resolvedURL, overrideURL) + } + + func test_bundleURLOverrideFallsBackWhenItReturnsNil() throws { + let metroURL = URL(string: "http://localhost:8081/index.bundle?platform=ios")! + let bundle = try makeFixtureBundle() + + let resolvedURL = try BrownfieldBundleURLResolver.resolve( + isDebug: true, + preferEmbeddedBundleInDebug: true, + bundlePath: "main.jsbundle", + bundle: bundle, + bundleURLOverride: { nil }, + metroURL: { metroURL } + ) + + XCTAssertNotNil(resolvedURL) + XCTAssertEqual(resolvedURL?.lastPathComponent, "main.jsbundle") + XCTAssertNotEqual(resolvedURL, metroURL) + } + + func test_invalidBundlePathThrows() { + XCTAssertThrowsError( + try BrownfieldBundleURLResolver.resolve( + isDebug: false, + preferEmbeddedBundleInDebug: false, + bundlePath: "mainjsbundle", + bundle: Bundle(for: Self.self), + bundleURLOverride: nil, + metroURL: { nil } + ) + ) { error in + guard case let BrownfieldBundlePathResolver.Error.invalidBundlePath(bundlePath) = error else { + return XCTFail("Expected invalid bundle path error, got \(error)") + } + + XCTAssertEqual(bundlePath, "mainjsbundle") + } + } + + private func makeFixtureBundle() throws -> Bundle { + let fileManager = FileManager.default + let bundleURL = fileManager.temporaryDirectory + .appendingPathComponent("BrownfieldBundleFixture-\(UUID().uuidString).bundle") + let contentsURL = bundleURL.appendingPathComponent("Contents") + let resourcesURL = contentsURL.appendingPathComponent("Resources") + let plistURL = contentsURL.appendingPathComponent("Info.plist") + let fixtureURL = resourcesURL.appendingPathComponent("main.jsbundle") + + try fileManager.createDirectory(at: resourcesURL, withIntermediateDirectories: true) + + let plist = """ + + + + + CFBundleIdentifier + com.callstack.BrownfieldBundleFixture + CFBundleName + BrownfieldBundleFixture + CFBundlePackageType + BNDL + CFBundleVersion + 1 + + + """ + + try plist.write(to: plistURL, atomically: true, encoding: .utf8) + try "console.log(\"fixture\");".write(to: fixtureURL, atomically: true, encoding: .utf8) + + addTeardownBlock { + try? fileManager.removeItem(at: bundleURL) + } + + guard let bundle = Bundle(url: bundleURL) else { + throw NSError( + domain: "BrownfieldBundleURLResolverTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create fixture bundle"] + ) + } + + return bundle + } +} diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withFmtFix.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withFmtFix.test.ts new file mode 100644 index 00000000..189e5097 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withFmtFix.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { injectFmtFixIntoPodfile } from '../withFmtFix'; + +describe('injectFmtFixIntoPodfile', () => { + it('injects the fmt fix into an Expo post_install block', () => { + const podfile = `target 'ExpoApp54' do + use_expo_modules! + + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + :ccache_enabled => ccache_enabled?(podfile_properties), + ) + end +end +`; + + const patched = injectFmtFixIntoPodfile(podfile); + + expect(patched).toContain( + '# Fix fmt 11.0.2 consteval compilation error with Xcode 26.4+' + ); + expect(patched).toContain( + "fmt_base = File.join(installer.sandbox.pod_dir('fmt'), 'include', 'fmt', 'base.h')" + ); + expect(patched).toMatch( + /react_native_post_install\([\s\S]*?\n\s+# Fix fmt 11\.0\.2 consteval compilation error with Xcode 26\.4\+\n[\s\S]*?\n\s+end\nend/ + ); + }); + + it('is idempotent when the fix is already present', () => { + const podfile = `post_install do |installer| + # Fix fmt 11.0.2 consteval compilation error with Xcode 26.4+ + fmt_base = File.join(installer.sandbox.pod_dir('fmt'), 'include', 'fmt', 'base.h') +end +`; + + expect(injectFmtFixIntoPodfile(podfile)).toBe(podfile); + }); +}); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts new file mode 100644 index 00000000..9876ea54 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/withIosFrameworkFiles.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { getFrameworkSourceFiles } from '../withIosFrameworkFiles'; +import type { ResolvedBrownfieldPluginIosConfig } from '../../types/ios/BrownfieldPluginIosConfig'; + +const iosConfig: ResolvedBrownfieldPluginIosConfig = { + frameworkName: 'BrownfieldLib', + bundleIdentifier: 'com.example.brownfield.framework', + deploymentTarget: '15.0', + frameworkVersion: '1', + buildSettings: {}, +}; + +describe('getFrameworkSourceFiles', () => { + it('renders the framework interface with an explicit bundle identifier lookup', () => { + const files = getFrameworkSourceFiles(iosConfig); + const frameworkInterface = files.find( + (file) => file.relativePath === 'BrownfieldLib.swift' + ); + + expect(frameworkInterface?.content).toContain( + 'Bundle(identifier: "com.example.brownfield.framework")' + ); + expect(frameworkInterface?.content).toContain( + 'Bundle.allFrameworks.first { $0.bundleIdentifier == "com.example.brownfield.framework" }' + ); + expect(frameworkInterface?.content).toContain( + 'Bundle(for: InternalClassForBundle.self)' + ); + }); +}); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts new file mode 100644 index 00000000..3494aee8 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { + getFrameworkBuildSettings, + rewriteBundleReactNativePhaseScriptForFrameworkTarget, +} from '../xcodeHelpers'; +import type { ResolvedBrownfieldPluginIosConfig } from '../../types'; + +const baseOptions: ResolvedBrownfieldPluginIosConfig = { + frameworkName: 'BrownfieldLib', + bundleIdentifier: 'com.example.brownfield', + deploymentTarget: '15.0', + frameworkVersion: '1', + buildSettings: {}, +}; + +describe('getFrameworkBuildSettings', () => { + it('uses rpath-based install settings for generated framework targets', () => { + const settings = getFrameworkBuildSettings( + { configuration: 'Debug' }, + baseOptions + ); + + expect(settings.DYLIB_INSTALL_NAME_BASE).toBe('"@rpath"'); + expect(settings.INSTALL_PATH).toBe('"$(LOCAL_LIBRARY_DIR)/Frameworks"'); + expect(settings.SKIP_INSTALL).toBe('NO'); + }); + + it('preserves custom build settings while keeping required framework settings', () => { + const settings = getFrameworkBuildSettings( + { configuration: 'Release' }, + { + ...baseOptions, + buildSettings: { + SWIFT_VERSION: '5.10', + MARKETING_VERSION: '9.9.9', + }, + } + ); + + expect(settings.DYLIB_INSTALL_NAME_BASE).toBe('"@rpath"'); + expect(settings.INSTALL_PATH).toBe('"$(LOCAL_LIBRARY_DIR)/Frameworks"'); + expect(settings.SWIFT_VERSION).toBe('5.10'); + expect(settings.MARKETING_VERSION).toBe('9.9.9'); + }); +}); + +describe('rewriteBundleReactNativePhaseScriptForFrameworkTarget', () => { + it('replaces Expo debug skip-bundling logic with a force-bundling override', () => { + const script = `if [[ "$CONFIGURATION" = *Debug* ]]; then + export SKIP_BUNDLING=1 +fi + +if [[ -z "$BUNDLE_COMMAND" ]]; then + export BUNDLE_COMMAND="export:embed" +fi + +\`"$NODE_BINARY" --print "require.resolve('react-native/package.json')"\`/scripts/react-native-xcode.sh +`; + + const rewritten = + rewriteBundleReactNativePhaseScriptForFrameworkTarget(script); + + expect(rewritten).toContain('unset SKIP_BUNDLING'); + expect(rewritten).toContain('export FORCE_BUNDLING=1'); + expect(rewritten).not.toContain('export SKIP_BUNDLING=1'); + expect(rewritten).toContain('export BUNDLE_COMMAND="export:embed"'); + expect(rewritten).toContain('react-native-xcode.sh'); + }); + + it('prepends the debug override when the source script has no Expo skip block', () => { + const script = `export ENTRY_FILE="index.js" +\`"$NODE_BINARY" --print "require.resolve('react-native/package.json')"\`/scripts/react-native-xcode.sh +`; + + const rewritten = + rewriteBundleReactNativePhaseScriptForFrameworkTarget(script); + + expect(rewritten).toMatch( + /^# Brownfield framework packaging must embed JS in Debug builds\.\nif \[\[ "\$CONFIGURATION" = \*Debug\* \]\]; then\n {2}unset SKIP_BUNDLING\n {2}export FORCE_BUNDLING=1\nfi\n\nexport ENTRY_FILE="index\.js"/ + ); + }); +}); 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 94795a06..96ee5572 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 @@ -11,6 +11,7 @@ import { copyBundleReactNativePhase, } from './xcodeHelpers'; import { modifyPodfile } from './podfileHelpers'; +import { injectFmtFixIntoPodfile } from './withFmtFix'; import { ensureFrameworkHasExpoPlistResource } from './utils/expo-updates'; import { withIosFrameworkFiles } from './withIosFrameworkFiles'; import type { ResolvedBrownfieldPluginConfigWithIos } from '../types'; @@ -55,9 +56,18 @@ export const withBrownfieldIos: ConfigPlugin< if (targetAlreadyExists) { Logger.logDebug( - `Skipping further Xcode modifications as framework target was already present` + `Framework target already present, syncing Brownfield build phases` ); + copyBundleReactNativePhase(project, frameworkTargetUUID); + + if (isExpoPre55) { + addExpoPre55ShellPatchScriptPhase(modRequest, project, { + frameworkName: props.ios.frameworkName, + frameworkTargetUUID: frameworkTargetUUID, + }); + } + return xcodeConfig; } @@ -89,11 +99,13 @@ export const withBrownfieldIos: ConfigPlugin< config = withPodfile(config, (podfileConfig) => { const { frameworkName } = props.ios; - podfileConfig.modResults.contents = modifyPodfile( + const modifiedPodfile = modifyPodfile( podfileConfig.modResults.contents, frameworkName, expoMajor ); + podfileConfig.modResults.contents = + injectFmtFixIntoPodfile(modifiedPodfile); return podfileConfig; }); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withFmtFix.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withFmtFix.ts new file mode 100644 index 00000000..99aaca4c --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withFmtFix.ts @@ -0,0 +1,72 @@ +import { withPodfile, type ConfigPlugin } from '@expo/config-plugins'; + +import type { ResolvedBrownfieldPluginConfigWithIos } from '../types'; + +const FMT_FIX_MARKER = + '# Fix fmt 11.0.2 consteval compilation error with Xcode 26.4+'; + +const FMT_FIX_RUBY = `\ + ${FMT_FIX_MARKER} + fmt_base = File.join(installer.sandbox.pod_dir('fmt'), 'include', 'fmt', 'base.h') + if File.exist?(fmt_base) + content = File.read(fmt_base) + patched = content.gsub(/^#\\s*define FMT_USE_CONSTEVAL 1$/, '# define FMT_USE_CONSTEVAL 0') + if patched != content + File.chmod(0644, fmt_base) + File.write(fmt_base, patched) + end + end`; + +export function injectFmtFixIntoPodfile(podfile: string): string { + if (podfile.includes(FMT_FIX_MARKER)) { + return podfile; + } + + const lines = podfile.split('\n'); + const postInstallIndex = lines.findIndex((line) => + /^\s*post_install do \|installer\|/.test(line) + ); + + if (postInstallIndex === -1) { + return podfile; + } + + let depth = 0; + let insertionIndex = -1; + + for (let index = postInstallIndex; index < lines.length; index += 1) { + const trimmed = lines[index].trim(); + + if (trimmed.endsWith(' do |installer|')) { + depth += 1; + continue; + } + + if (trimmed === 'end') { + depth -= 1; + + if (depth === 0) { + insertionIndex = index; + break; + } + } + } + + if (insertionIndex === -1) { + return podfile; + } + + lines.splice(insertionIndex, 0, FMT_FIX_RUBY); + return lines.join('\n'); +} + +export const withFmtFix: ConfigPlugin = ( + config +) => + withPodfile(config, (podfileConfig) => { + podfileConfig.modResults.contents = injectFmtFixIntoPodfile( + podfileConfig.modResults.contents + ); + + return podfileConfig; + }); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts index 46823be4..3c652ab1 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts @@ -21,7 +21,9 @@ export function getFrameworkSourceFiles( return [ { relativePath: `${ios.frameworkName}.swift`, - content: renderTemplate('ios', 'FrameworkInterface.swift', {}), + content: renderTemplate('ios', 'FrameworkInterface.swift', { + '{{BUNDLE_IDENTIFIER}}': ios.bundleIdentifier, + }), }, { relativePath: 'Info.plist', @@ -44,17 +46,15 @@ export function createIosFramework( const { ios } = config; const frameworkDir = path.join(iosDir, ios.frameworkName); - // check if framework directory if it exists - if (fs.existsSync(frameworkDir)) { - Logger.logDebug(`Framework directory already exists: ${frameworkDir}`); - - return; - } - - Logger.logDebug(`Creating iOS framework in: ${frameworkDir}`); + const frameworkDirExists = fs.existsSync(frameworkDir); + Logger.logDebug( + frameworkDirExists + ? `Updating iOS framework files in: ${frameworkDir}` + : `Creating iOS framework in: ${frameworkDir}` + ); // create framework directory - if (!fs.existsSync(frameworkDir)) { + if (!frameworkDirExists) { fs.mkdirSync(frameworkDir, { recursive: true }); Logger.logDebug(`Created directory: ${frameworkDir}`); 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 a63cff2a..952122fb 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 @@ -401,7 +401,7 @@ export function ensureTargetHasFileReferenceInResourcesBuildPhase( * @param options The user configuration * @returns Build settings object */ -function getFrameworkBuildSettings( +export function getFrameworkBuildSettings( { configuration, }: { @@ -424,6 +424,8 @@ function getFrameworkBuildSettings( USER_SCRIPT_SANDBOXING: 'NO', SKIP_INSTALL: 'NO', ENABLE_MODULE_VERIFIER: 'NO', + DYLIB_INSTALL_NAME_BASE: '"@rpath"', + INSTALL_PATH: '"$(LOCAL_LIBRARY_DIR)/Frameworks"', // basic settings PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`, @@ -442,6 +444,62 @@ function getFrameworkBuildSettings( }; } +export function rewriteBundleReactNativePhaseScriptForFrameworkTarget( + shellScript: string +): string { + const debugBundlingOverride = `# Brownfield framework packaging must embed JS in Debug builds. +if [[ "$CONFIGURATION" = *Debug* ]]; then + unset SKIP_BUNDLING + export FORCE_BUNDLING=1 +fi +`; + const debugSkipBundlingBlock = + /if \[\[ "\$CONFIGURATION" = \*Debug\* \]\]; then\s+export SKIP_BUNDLING=1\s+fi\s*/m; + + if (debugSkipBundlingBlock.test(shellScript)) { + return shellScript.replace( + debugSkipBundlingBlock, + `${debugBundlingOverride}\n` + ); + } + + if (shellScript.includes('export FORCE_BUNDLING=1')) { + return shellScript; + } + + return `${debugBundlingOverride}\n${shellScript}`; +} + +function decodePbxString(value: string | undefined): string { + if (!value) { + return ''; + } + + if (value.startsWith('"') && value.endsWith('"')) { + try { + return JSON.parse(value) as string; + } catch { + return value.slice(1, -1).replace(/\\"/g, '"'); + } + } + + return value; +} + +function encodePbxString(value: string): string { + return JSON.stringify(value); +} + +function hasBuildPhaseComment( + phase: { comment?: string }, + expectedComment: string +): boolean { + return ( + phase.comment === expectedComment || + phase.comment === `"${expectedComment}"` + ); +} + /** * Finds the "Bundle React Native code and images" build phase from the main app target * and adds it to the framework target's build phases @@ -465,40 +523,85 @@ export function copyBundleReactNativePhase( // find the phase by name let existingPhaseUuid: string | null = null; + let existingPhase: Record | null = null; for (const key of Object.keys(shellScriptPhases)) { if (key.endsWith('_comment')) continue; const phase = shellScriptPhases[key]; if (phase.name === `"${buildPhaseName}"` || phase.name === buildPhaseName) { existingPhaseUuid = key; + existingPhase = phase; break; } } - if (!existingPhaseUuid) { + if (!existingPhaseUuid || !existingPhase) { throw new SourceModificationError( `Could not find "${buildPhaseName}" build phase, skipping` ); } - // add the phase reference to the framework target's buildPhases array const nativeTargets = project.hash.project.objects.PBXNativeTarget; if (nativeTargets && nativeTargets[targetUuid]) { const target = nativeTargets[targetUuid]; if (target.buildPhases) { - // check if phase is already added - if ( - !target.buildPhases.some( - (phase: { value: string }) => phase.value === existingPhaseUuid - ) - ) { - target.buildPhases.push( - createPbxCommentedReference(existingPhaseUuid, buildPhaseName) + const targetPhaseIndex = target.buildPhases.findIndex( + (phase: { comment?: string }) => + hasBuildPhaseComment(phase, buildPhaseName) + ); + const frameworkShellPath = + decodePbxString(existingPhase.shellPath) || '/bin/sh'; + const frameworkShellScript = + rewriteBundleReactNativePhaseScriptForFrameworkTarget( + decodePbxString(existingPhase.shellScript) ); - Logger.logDebug( - `Added "${buildPhaseName}" build phase to framework target ${target.name}` - ); + if (targetPhaseIndex !== -1) { + const currentPhaseUuid = target.buildPhases[targetPhaseIndex].value; + const currentPhase = shellScriptPhases[currentPhaseUuid]; + + if (currentPhase && currentPhaseUuid !== existingPhaseUuid) { + currentPhase.inputPaths = existingPhase.inputPaths ?? []; + currentPhase.outputPaths = existingPhase.outputPaths ?? []; + currentPhase.shellPath = encodePbxString(frameworkShellPath); + currentPhase.shellScript = encodePbxString(frameworkShellScript); + + if (existingPhase.showEnvVarsInLog !== undefined) { + currentPhase.showEnvVarsInLog = existingPhase.showEnvVarsInLog; + } + + Logger.logDebug( + `Updated framework-specific "${buildPhaseName}" build phase on target ${target.name}` + ); + return; + } + + if (currentPhaseUuid === existingPhaseUuid) { + target.buildPhases.splice(targetPhaseIndex, 1); + } else { + return; + } } + + const addedPhase = project.addBuildPhase( + [], + 'PBXShellScriptBuildPhase', + buildPhaseName, + targetUuid, + { + inputPaths: existingPhase.inputPaths ?? [], + outputPaths: existingPhase.outputPaths ?? [], + shellPath: frameworkShellPath, + shellScript: frameworkShellScript, + } + ); + + if (existingPhase.showEnvVarsInLog !== undefined) { + addedPhase.buildPhase.showEnvVarsInLog = existingPhase.showEnvVarsInLog; + } + + Logger.logDebug( + `Added framework-specific "${buildPhaseName}" build phase to target ${target.name}` + ); } } } @@ -582,6 +685,16 @@ export function addExpoPre55ShellPatchScriptPhase( ); } + const existingBuildPhases = + project.pbxNativeTargetSection()[frameworkTargetUUID]?.buildPhases ?? []; + if ( + existingBuildPhases.some((phase: { comment?: string }) => + hasBuildPhaseComment(phase, 'Patch ExpoModulesProvider') + ) + ) { + return; + } + project.addBuildPhase( [ // no associated files diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.swift b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.swift index bbff2930..a2dbeb1d 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.swift +++ b/packages/react-native-brownfield/src/expo-config-plugin/template/ios/FrameworkInterface.swift @@ -2,7 +2,10 @@ import Foundation import ReactBrownfield // Initializes a Bundle instance that points at the framework target. -public let ReactNativeBundle = Bundle(for: InternalClassForBundle.self) +public let ReactNativeBundle = + Bundle(identifier: "{{BUNDLE_IDENTIFIER}}") + ?? Bundle.allFrameworks.first { $0.bundleIdentifier == "{{BUNDLE_IDENTIFIER}}" } + ?? Bundle(for: InternalClassForBundle.self) class InternalClassForBundle {}