diff --git a/.changeset/tender-poems-rhyme.md b/.changeset/tender-poems-rhyme.md new file mode 100644 index 00000000..3c92820a --- /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 `preferBundledBundleInDebug`, 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 8f12d7b3..bc09be78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,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 + 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 51764612..2115113f 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ secring.gpg # Typescript **/*.tsbuildinfo +packages/react-native-brownfield/ios/.build/ diff --git a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift index 22e2e83f..97e65f41 100644 --- a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift +++ b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift @@ -75,6 +75,7 @@ struct BrownfieldAppleApp: App { init() { ReactNativeBrownfield.shared.bundle = ReactNativeBundle + ReactNativeBrownfield.shared.preferBundledBundleInDebug = 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..9b0e3f91 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. | +| `preferBundledBundleInDebug` | `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..00ab217f 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. | +| `preferBundledBundleInDebug` | `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..daf326af 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.preferBundledBundleInDebug = 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..f97463fa 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -171,6 +171,14 @@ When running in **Debug**, React Native Brownfield expects a JS dev server runni npx react-native start ``` +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.preferBundledBundleInDebug = 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/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt index 780059a3..c6b745d1 100644 --- a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt +++ b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/BrownfieldNavigationDelegate.kt @@ -1,3 +1,6 @@ package com.callstack.nativebrownfieldnavigation -interface BrownfieldNavigationDelegate +interface BrownfieldNavigationDelegate { + fun navigateToSettings() + fun navigateToReferrals(userId: String) +} diff --git a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt index f118105a..f47b94ff 100644 --- a/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt +++ b/packages/brownfield-navigation/android/src/main/java/com/callstack/nativebrownfieldnavigation/NativeBrownfieldNavigationModule.kt @@ -1,6 +1,5 @@ package com.callstack.nativebrownfieldnavigation -import android.util.Log import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod @@ -8,8 +7,13 @@ class NativeBrownfieldNavigationModule( reactContext: ReactApplicationContext ) : NativeBrownfieldNavigationSpec(reactContext) { @ReactMethod - override fun temporary() { - Log.d(NAME, "temporary") + override fun navigateToSettings() { + BrownfieldNavigationManager.getDelegate().navigateToSettings() + } + + @ReactMethod + override fun navigateToReferrals(userId: String) { + BrownfieldNavigationManager.getDelegate().navigateToReferrals(userId) } companion object { diff --git a/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift b/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift index f4b2828d..c84e9651 100644 --- a/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift +++ b/packages/brownfield-navigation/ios/BrownfieldNavigationDelegate.swift @@ -1,5 +1,6 @@ import Foundation @objc public protocol BrownfieldNavigationDelegate: AnyObject { - + @objc func navigateToSettings() + @objc func navigateToReferrals(_ userId: String) } diff --git a/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm b/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm index d92e3ef0..28371c45 100644 --- a/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm +++ b/packages/brownfield-navigation/ios/NativeBrownfieldNavigation.mm @@ -8,8 +8,12 @@ @implementation NativeBrownfieldNavigation -- (void)temporary { - NSLog(@"temporary"); +- (void)navigateToSettings { + [[[BrownfieldNavigationManager shared] getDelegate] navigateToSettings]; +} + +- (void)navigateToReferrals:(NSString *)userId { + [[[BrownfieldNavigationManager shared] getDelegate] navigateToReferrals:userId]; } - (std::shared_ptr)getTurboModule: diff --git a/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts b/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts index 5d4ed84d..4d620932 100644 --- a/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts +++ b/packages/brownfield-navigation/src/NativeBrownfieldNavigation.ts @@ -1,7 +1,8 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; export interface Spec extends TurboModule { - temporary(): void; + navigateToSettings(): void; + navigateToReferrals(userId: string): void; } export default TurboModuleRegistry.getEnforcing( diff --git a/packages/brownfield-navigation/src/index.ts b/packages/brownfield-navigation/src/index.ts index 5af516da..81704d0d 100644 --- a/packages/brownfield-navigation/src/index.ts +++ b/packages/brownfield-navigation/src/index.ts @@ -1,8 +1,11 @@ import NativeBrownfieldNavigation from './NativeBrownfieldNavigation'; const BrownfieldNavigation = { - temporary: () => { - NativeBrownfieldNavigation.temporary(); + navigateToSettings: () => { + NativeBrownfieldNavigation.navigateToSettings(); + }, + navigateToReferrals: (userId: string) => { + NativeBrownfieldNavigation.navigateToReferrals(userId); }, }; 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 d839b157..a9abe22c 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/Package.swift", "ios/Tests/**/*" spec.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'OTHER_SWIFT_FLAGS' => "-enable-experimental-feature AccessLevelOnImport" diff --git a/packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift b/packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift new file mode 100644 index 00000000..95881ffc --- /dev/null +++ b/packages/react-native-brownfield/ios/BrownfieldBundleURLResolver.swift @@ -0,0 +1,26 @@ +import Foundation + +enum BrownfieldBundleURLResolver { + static func resolve( + isDebug: Bool, + preferBundledBundleInDebug: Bool, + bundlePath: String, + bundle: Bundle, + bundleURLOverride: (() -> URL?)?, + metroURL: () -> URL? + ) throws -> URL? { + if let overriddenURL = bundleURLOverride?() { + return overriddenURL + } + + if isDebug && !preferBundledBundleInDebug { + 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/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 2beb99fe..fd2ae569 100644 --- a/packages/react-native-brownfield/ios/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/ExpoHostRuntime.swift @@ -14,6 +14,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 = + preferBundledBundleInDebug && (resolvedBundleURL?.isFileURL ?? false) + + BrownfieldDevLoadingViewBridge.setEnabled(!shouldDisableDevLoadingView) + #endif + } + /** * Starts React Native with default parameters. */ @@ -28,6 +38,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) @@ -85,6 +97,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 preferBundledBundleInDebug: Bool = false { + didSet { + delegate.preferBundledBundleInDebug = preferBundledBundleInDebug + } + } /** * Dynamic bundle URL provider called on every bundle load. * When set, this overrides the default bundleURL() behavior in the delegate. @@ -132,6 +154,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 @@ -157,6 +180,7 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { var entryFile = ".expo/.virtual-metro-entry" var bundlePath = "main.jsbundle" var bundle = Bundle.main + var preferBundledBundleInDebug = false var bundleURLOverride: (() -> URL?)? = nil override func sourceURL(for bridge: RCTBridge) -> URL? { @@ -165,21 +189,28 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { } 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, + preferBundledBundleInDebug: preferBundledBundleInDebug, + 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 } } #endif diff --git a/packages/react-native-brownfield/ios/Package.swift b/packages/react-native-brownfield/ios/Package.swift new file mode 100644 index 00000000..1351ebd8 --- /dev/null +++ b/packages/react-native-brownfield/ios/Package.swift @@ -0,0 +1,48 @@ +// 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: ".", + exclude: [ + "BrownfieldDevLoadingViewBridge.h", + "BrownfieldDevLoadingViewBridge.m", + "ExpoHostRuntime.swift", + "JSBundleLoadObserver.swift", + "Notification+Brownfield.swift", + "ReactNativeBrownfield.swift", + "ReactNativeBrownfield.xcodeproj", + "ReactNativeBrownfieldModule.h", + "ReactNativeBrownfieldModule.mm", + "ReactNativeBrownfieldModule.swift", + "ReactNativeHostRuntime.swift", + "ReactNativeView.swift", + "ReactNativeViewController.swift", + "Tests", + ], + sources: [ + "BrownfieldBundlePathResolver.swift", + "BrownfieldBundleURLResolver.swift", + ] + ), + .testTarget( + name: "BrownfieldBundleSupportTests", + dependencies: ["BrownfieldBundleSupport"], + path: "Tests", + exclude: ["Fixtures"] + ), + ] +) diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift index be72ab95..35dac1a8 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 preferBundledBundleInDebug: Bool = false { + didSet { + #if canImport(Expo) + ExpoHostRuntime.shared.preferBundledBundleInDebug = preferBundledBundleInDebug + #else + ReactNativeHostRuntime.shared.preferBundledBundleInDebug = preferBundledBundleInDebug + #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 097dc0a2..50feb565 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 preferBundledBundleInDebug = 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, + preferBundledBundleInDebug: preferBundledBundleInDebug, + 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 = + preferBundledBundleInDebug && (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 preferBundledBundleInDebug: Bool = false { + didSet { + delegate.preferBundledBundleInDebug = preferBundledBundleInDebug + } + } + /** * 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 @@ -153,6 +179,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/Tests/BrownfieldBundleURLResolverTests.swift b/packages/react-native-brownfield/ios/Tests/BrownfieldBundleURLResolverTests.swift new file mode 100644 index 00000000..e1262920 --- /dev/null +++ b/packages/react-native-brownfield/ios/Tests/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, + preferBundledBundleInDebug: 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, + preferBundledBundleInDebug: 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, + preferBundledBundleInDebug: 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, + preferBundledBundleInDebug: 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, + preferBundledBundleInDebug: 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, + preferBundledBundleInDebug: 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__/xcodeHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts new file mode 100644 index 00000000..68bc0182 --- /dev/null +++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/__tests__/xcodeHelpers.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { getFrameworkBuildSettings } 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'); + }); +}); 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 82f577eb..8b7355b7 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 @@ -187,7 +187,7 @@ export function addSourceFilesBuildPhase( * @param options The user configuration * @returns Build settings object */ -function getFrameworkBuildSettings( +export function getFrameworkBuildSettings( { configuration, }: { @@ -210,6 +210,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}"`,