diff --git a/.changeset/olive-parrots-invite.md b/.changeset/olive-parrots-invite.md new file mode 100644 index 00000000..a95a6bbd --- /dev/null +++ b/.changeset/olive-parrots-invite.md @@ -0,0 +1,5 @@ +--- +'@callstack/brownfield-cli': patch +--- + +respect `--destination` in `package:ios` when merging XCFrameworks diff --git a/.yarn/patches/@rock-js-plugin-brownfield-ios-npm-0.14.0-0152061a4e.patch b/.yarn/patches/@rock-js-plugin-brownfield-ios-npm-0.14.0-0152061a4e.patch new file mode 100644 index 00000000..3871839a --- /dev/null +++ b/.yarn/patches/@rock-js-plugin-brownfield-ios-npm-0.14.0-0152061a4e.patch @@ -0,0 +1,263 @@ +diff --git a/dist/src/lib/pluginBrownfieldIos.js b/dist/src/lib/pluginBrownfieldIos.js +index 6a6a5ba89cd0327ab0accf08099fe3fd0b9fe9b9..a54908fd0547f28222346738fa01bfbf9872f66b 100644 +--- a/dist/src/lib/pluginBrownfieldIos.js ++++ b/dist/src/lib/pluginBrownfieldIos.js +@@ -1,8 +1,52 @@ ++import fs from 'node:fs'; + import path from 'node:path'; + import { buildApp, genericDestinations, getBuildOptions, getBuildPaths, getValidProjectConfig, mergeFrameworks, } from '@rock-js/platform-apple-helpers'; +-import { colorLink, intro, logger, outro, relativeToCwd } from '@rock-js/tools'; ++import { colorLink, intro, logger, outro, relativeToCwd, RockError } from '@rock-js/tools'; + import { copyHermesXcframework } from './copyHermesXcframework.js'; + import { copyReactXcframeworks } from './copyReactXcframeworks.js'; ++const ALL_SDKS = ['iphoneos', 'iphonesimulator']; ++// `--destination` narrows which slices Xcode emits, so the merges below must only ++// look at the slices that were actually built. ++function resolveSdksForDestination(destination) { ++ const normalized = String(destination).trim().toLowerCase(); ++ if (normalized === 'device') { ++ return ['iphoneos']; ++ } ++ if (normalized === 'simulator' || normalized.includes('simulator')) { ++ return ['iphonesimulator']; ++ } ++ if (normalized.includes('platform=ios')) { ++ return ['iphoneos']; ++ } ++ // `id=` / `name=iPhone 17` can be either, so keep both rather than ++ // silently dropping a slice. ++ return ALL_SDKS; ++} ++function resolveDestinationSdks(destinations) { ++ const sdks = new Set(destinations.flatMap(resolveSdksForDestination)); ++ return ALL_SDKS.filter((sdk) => sdks.has(sdk)); ++} ++// `mergeFrameworks` also accepts a directory holding only the static library, which ++// it wraps into a temporary framework. ++function hasBuildProduct(directoryPath, frameworkName) { ++ return (fs.existsSync(path.join(directoryPath, `${frameworkName}.framework`)) || ++ fs.existsSync(path.join(directoryPath, `lib${frameworkName}.a`))); ++} ++function collectFrameworkPaths({ productsPath, configuration, sdks, frameworkName, productSubDir, }) { ++ const searchedDirectories = sdks.map((sdk) => path.join(productsPath, `${configuration}-${sdk}`, ...(productSubDir ? [productSubDir] : []))); ++ const frameworkPaths = searchedDirectories ++ .filter((directoryPath) => hasBuildProduct(directoryPath, frameworkName)) ++ .map((directoryPath) => path.join(directoryPath, `${frameworkName}.framework`)); ++ // an empty list would surface as xcodebuild's opaque ++ // "at least one framework or library must be specified" ++ if (frameworkPaths.length === 0) { ++ throw new RockError(`Could not find a build product for ${frameworkName} in the ${configuration} configuration. ` + ++ `Looked for ${frameworkName}.framework or lib${frameworkName}.a in:\n` + ++ searchedDirectories.map((dir) => ` - ${dir}`).join('\n') + ++ `\nIf the build produced an .app instead of a framework, the wrong scheme was built; pass --scheme with your brownfield framework scheme.`); ++ } ++ return frameworkPaths; ++} + const buildOptions = getBuildOptions({ platformName: 'ios' }); + export const packageIosAction = async (args, { projectRoot, reactNativePath, reactNativeVersion, usePrebuiltRNCore, skipCache, packageDir, }, pluginConfig) => { + intro('Packaging iOS project'); +@@ -13,6 +57,7 @@ export const packageIosAction = async (args, { projectRoot, reactNativePath, rea + genericDestinations.ios.device, + genericDestinations.ios.simulator, + ]; ++ const sdks = resolveDestinationSdks(destination); + const buildFolder = args.buildFolder ?? getBuildPaths('ios').derivedDataDir; + const configuration = args.configuration ?? 'Debug'; + const { appPath, scheme } = await buildApp({ +@@ -37,19 +82,24 @@ export const packageIosAction = async (args, { projectRoot, reactNativePath, rea + getBuildPaths('ios').packageDir; + await mergeFrameworks({ + sourceDir, +- frameworkPaths: [ +- path.join(productsPath, `${configuration}-iphoneos`, `${scheme}.framework`), +- path.join(productsPath, `${configuration}-iphonesimulator`, `${scheme}.framework`), +- ], ++ frameworkPaths: collectFrameworkPaths({ ++ productsPath, ++ configuration, ++ sdks, ++ frameworkName: scheme, ++ }), + outputPath: path.join(frameworkTargetOutputDir, `${scheme}.xcframework`), + }); + // 3) Merge React Native Brownfield paths + await mergeFrameworks({ + sourceDir, +- frameworkPaths: [ +- path.join(productsPath, `${configuration}-iphoneos`, 'ReactBrownfield', 'ReactBrownfield.framework'), +- path.join(productsPath, `${configuration}-iphonesimulator`, 'ReactBrownfield', 'ReactBrownfield.framework'), +- ], ++ frameworkPaths: collectFrameworkPaths({ ++ productsPath, ++ configuration, ++ sdks, ++ frameworkName: 'ReactBrownfield', ++ productSubDir: 'ReactBrownfield', ++ }), + outputPath: path.join(frameworkTargetOutputDir, 'ReactBrownfield.xcframework'), + }); + // 4) Copy hermes xcframework to the output path +diff --git a/src/lib/pluginBrownfieldIos.ts b/src/lib/pluginBrownfieldIos.ts +index 1dbe3343e433a2322e9e5087f5d138b4e5ef496a..cd362ec206aae115829e7348cce459494aec44c1 100644 +--- a/src/lib/pluginBrownfieldIos.ts ++++ b/src/lib/pluginBrownfieldIos.ts +@@ -1,3 +1,4 @@ ++import fs from 'node:fs'; + import path from 'node:path'; + import type { IOSProjectConfig } from '@react-native-community/cli-types'; + import type { PluginApi, PluginOutput } from '@rock-js/config'; +@@ -10,10 +11,92 @@ import { + getValidProjectConfig, + mergeFrameworks, + } from '@rock-js/platform-apple-helpers'; +-import { colorLink, intro, logger, outro, relativeToCwd } from '@rock-js/tools'; ++import { colorLink, intro, logger, outro, relativeToCwd, RockError } from '@rock-js/tools'; + import { copyHermesXcframework } from './copyHermesXcframework.js'; + import { copyReactXcframeworks } from './copyReactXcframeworks.js'; + ++type AppleSdk = 'iphoneos' | 'iphonesimulator'; ++ ++const ALL_SDKS: AppleSdk[] = ['iphoneos', 'iphonesimulator']; ++ ++// `--destination` narrows which slices Xcode emits, so the merges below must only ++// look at the slices that were actually built. ++function resolveSdksForDestination(destination: string): AppleSdk[] { ++ const normalized = String(destination).trim().toLowerCase(); ++ ++ if (normalized === 'device') { ++ return ['iphoneos']; ++ } ++ ++ if (normalized === 'simulator' || normalized.includes('simulator')) { ++ return ['iphonesimulator']; ++ } ++ ++ if (normalized.includes('platform=ios')) { ++ return ['iphoneos']; ++ } ++ ++ // `id=` / `name=iPhone 17` can be either, so keep both rather than ++ // silently dropping a slice. ++ return ALL_SDKS; ++} ++ ++function resolveDestinationSdks(destinations: string[]): AppleSdk[] { ++ const sdks = new Set(destinations.flatMap(resolveSdksForDestination)); ++ ++ return ALL_SDKS.filter((sdk) => sdks.has(sdk)); ++} ++ ++// `mergeFrameworks` also accepts a directory holding only the static library, which ++// it wraps into a temporary framework. ++function hasBuildProduct(directoryPath: string, frameworkName: string) { ++ return ( ++ fs.existsSync(path.join(directoryPath, `${frameworkName}.framework`)) || ++ fs.existsSync(path.join(directoryPath, `lib${frameworkName}.a`)) ++ ); ++} ++ ++function collectFrameworkPaths({ ++ productsPath, ++ configuration, ++ sdks, ++ frameworkName, ++ productSubDir, ++}: { ++ productsPath: string; ++ configuration: string; ++ sdks: AppleSdk[]; ++ frameworkName: string; ++ productSubDir?: string; ++}): string[] { ++ const searchedDirectories = sdks.map((sdk) => ++ path.join( ++ productsPath, ++ `${configuration}-${sdk}`, ++ ...(productSubDir ? [productSubDir] : []), ++ ), ++ ); ++ ++ const frameworkPaths = searchedDirectories ++ .filter((directoryPath) => hasBuildProduct(directoryPath, frameworkName)) ++ .map((directoryPath) => ++ path.join(directoryPath, `${frameworkName}.framework`), ++ ); ++ ++ // an empty list would surface as xcodebuild's opaque ++ // "at least one framework or library must be specified" ++ if (frameworkPaths.length === 0) { ++ throw new RockError( ++ `Could not find a build product for ${frameworkName} in the ${configuration} configuration. ` + ++ `Looked for ${frameworkName}.framework or lib${frameworkName}.a in:\n` + ++ searchedDirectories.map((dir) => ` - ${dir}`).join('\n') + ++ `\nIf the build produced an .app instead of a framework, the wrong scheme was built; pass --scheme with your brownfield framework scheme.`, ++ ); ++ } ++ ++ return frameworkPaths; ++} ++ + const buildOptions = getBuildOptions({ platformName: 'ios' }); + + export const packageIosAction = async ( +@@ -45,6 +128,8 @@ export const packageIosAction = async ( + genericDestinations.ios.simulator, + ]; + ++ const sdks = resolveDestinationSdks(destination); ++ + const buildFolder = args.buildFolder ?? getBuildPaths('ios').derivedDataDir; + const configuration = args.configuration ?? 'Debug'; + +@@ -73,38 +158,25 @@ export const packageIosAction = async ( + + await mergeFrameworks({ + sourceDir, +- frameworkPaths: [ +- path.join( +- productsPath, +- `${configuration}-iphoneos`, +- `${scheme}.framework`, +- ), +- path.join( +- productsPath, +- `${configuration}-iphonesimulator`, +- `${scheme}.framework`, +- ), +- ], ++ frameworkPaths: collectFrameworkPaths({ ++ productsPath, ++ configuration, ++ sdks, ++ frameworkName: scheme, ++ }), + outputPath: path.join(frameworkTargetOutputDir, `${scheme}.xcframework`), + }); + + // 3) Merge React Native Brownfield paths + await mergeFrameworks({ + sourceDir, +- frameworkPaths: [ +- path.join( +- productsPath, +- `${configuration}-iphoneos`, +- 'ReactBrownfield', +- 'ReactBrownfield.framework', +- ), +- path.join( +- productsPath, +- `${configuration}-iphonesimulator`, +- 'ReactBrownfield', +- 'ReactBrownfield.framework', +- ), +- ], ++ frameworkPaths: collectFrameworkPaths({ ++ productsPath, ++ configuration, ++ sdks, ++ frameworkName: 'ReactBrownfield', ++ productSubDir: 'ReactBrownfield', ++ }), + outputPath: path.join( + frameworkTargetOutputDir, + 'ReactBrownfield.xcframework', diff --git a/package.json b/package.json index ade1e8fb..e7d75535 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "resolutions": { "@babel/core": "7.29.7", + "@rock-js/plugin-brownfield-ios": "patch:@rock-js/plugin-brownfield-ios@npm%3A0.14.0#~/.yarn/patches/@rock-js-plugin-brownfield-ios-npm-0.14.0-0152061a4e.patch", "@types/react": "19.1.1", "chromium-edge-launcher": "0.3.0", "detox/glob": "npm:7.2.3" diff --git a/packages/cli/package.json b/packages/cli/package.json index d50dd330..288d5b1f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -91,7 +91,7 @@ "@rock-js/platform-android": "^0.14.0", "@rock-js/platform-apple-helpers": "^0.14.0", "@rock-js/plugin-brownfield-android": "^0.14.0", - "@rock-js/plugin-brownfield-ios": "^0.14.0", + "@rock-js/plugin-brownfield-ios": "patch:@rock-js/plugin-brownfield-ios@npm%3A0.14.0#~/.yarn/patches/@rock-js-plugin-brownfield-ios-npm-0.14.0-0152061a4e.patch", "@rock-js/tools": "^0.14.0", "ajv": "^8.20.0", "commander": "^14.0.3", diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts index 1495e812..a5735fdd 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts @@ -199,6 +199,7 @@ describe('package:ios action --add-spm-package', () => { productsPath: '/repo/ios/.brownfield/build/Build/Products', configuration: 'Release', frameworkName: 'BrownfieldLib', + sdks: ['iphoneos', 'iphonesimulator'], }); expect(mockCreateLocalSpmPackage).toHaveBeenCalledWith({ packageDir: '/repo/ios/.brownfield/package/build', @@ -222,11 +223,14 @@ describe('package:ios action --add-spm-package', () => { 'Release', ]); - expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith({ - projectRoot: '/repo', - packageDir: '/repo/ios/.brownfield/package/build', - usePrebuiltExpo: false, - }); + expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith( + expect.objectContaining({ + projectRoot: '/repo', + packageDir: '/repo/ios/.brownfield/package/build', + usePrebuiltExpo: false, + usePrebuiltExpoExplicit: true, + }) + ); expect(mockCreateLocalSpmPackage).toHaveBeenCalledWith({ packageDir: '/repo/ios/.brownfield/package/build', frameworkName: 'BrownfieldLib', @@ -276,6 +280,37 @@ describe('package:ios action --add-spm-package', () => { expect(processExitMock).toHaveBeenCalledWith(1); }); + test('marks an explicit --use-prebuilt-expo true as explicit', async () => { + await invokePackageIosAction([ + '--use-prebuilt-expo', + 'true', + '--configuration', + 'Release', + ]); + + expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith( + expect.objectContaining({ + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: true, + }) + ); + }); + + test('passes SPM generation the degraded value when Expo prebuilts are missing', async () => { + mockEmitExpoSupportXcframeworks.mockImplementation( + ({ onDegradeToSource }: { onDegradeToSource?: () => void }) => { + onDegradeToSource?.(); + return true; + } + ); + + await invokePackageIosAction(['--add-spm-package', '--configuration', 'Release']); + + expect(mockCreateLocalSpmPackage).toHaveBeenCalledWith( + expect.objectContaining({ usePrebuiltExpo: false }) + ); + }); + test('runs Expo framework emission before local SPM package creation for Expo SDK 56+', async () => { mockEmitExpoSupportXcframeworks.mockReturnValue(true); @@ -285,11 +320,15 @@ describe('package:ios action --add-spm-package', () => { 'Release', ]); - expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith({ - projectRoot: '/repo', - packageDir: '/repo/ios/.brownfield/package/build', - usePrebuiltExpo: true, - }); + // omitted flag => value is version-inferred, not explicit + expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith( + expect.objectContaining({ + projectRoot: '/repo', + packageDir: '/repo/ios/.brownfield/package/build', + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: false, + }) + ); expect( mockEmitExpoSupportXcframeworks.mock.invocationCallOrder[0] ).toBeLessThan(mockCreateLocalSpmPackage.mock.invocationCallOrder[0] ?? 0); diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts index 243fe98a..760db465 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts @@ -3,6 +3,7 @@ import * as rockTools from '@rock-js/tools'; import { describe, expect, test } from 'vitest'; import { + isUsePrebuiltExpoExplicit, packageIosCommand, parseUsePrebuiltExpoArgument, parseUsePrebuiltRnCoreArgument, @@ -120,3 +121,14 @@ describe('--add-spm-package (Commander)', () => { ); }); }); + +describe('isUsePrebuiltExpoExplicit', () => { + test('is false when the value is omitted (version-inferred later)', () => { + expect(isUsePrebuiltExpoExplicit(undefined)).toBe(false); + }); + + test('is true for explicit true and false (CLI or brownfield.config.json)', () => { + expect(isUsePrebuiltExpoExplicit(true)).toBe(true); + expect(isUsePrebuiltExpoExplicit(false)).toBe(true); + }); +}); diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index b50ed3ae..19ba345e 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -29,6 +29,10 @@ import { import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; import { copyDebugBundleToSimulatorSlice } from '../utils/copyDebugBundleToSimulatorSlice.js'; +import { + collectFrameworkPaths, + resolveDestinationSdks, +} from '../utils/destinationSdks.js'; import { resolvePackagedFrameworkName } from '../utils/resolvePackagedFrameworkName.js'; import { stripFrameworkBinary } from '../utils/stripFrameworkBinary.js'; import type { PackageIosOptions } from '../../types.js'; @@ -125,6 +129,11 @@ export const packageIosCommand = curryOptions( .action( actionRunner(async (cliOptions: PackageIosOptions) => { const options = mergeBrownfieldConfigWithOptions(cliOptions, 'ios'); + // captured before the version-aware default below, so that a value from + // brownfield.config.json counts as explicit + const usePrebuiltExpoExplicit = isUsePrebuiltExpoExplicit( + options.usePrebuiltExpo + ); const projectRoot = findProjectRoot(); await runExpoPrebuildIfNeeded({ projectRoot, platform: 'ios' }); @@ -197,6 +206,9 @@ export const packageIosCommand = curryOptions( // Reference: https://github.com/facebook/react-native/blob/490c5e8dcc6cdb19c334cc39e93a39a48ba71e96/packages/react-native/scripts/cocoapods/new_architecture.rb#L171 const packageDir = path.join(dotBrownfieldDir, 'package', 'build'); const configuration = options.configuration ?? 'Debug'; + // `--destination` narrows which slices Xcode emits, so every path below has to + // look at those slices only instead of assuming a device + simulator pair + const sdks = resolveDestinationSdks(options.destination); const { hasBrownie } = await runBrownieCodegenIfApplicable( projectRoot, @@ -225,6 +237,11 @@ export const packageIosCommand = curryOptions( projectRoot, packageDir, usePrebuiltExpo: options.usePrebuiltExpo, + usePrebuiltExpoExplicit, + // keep the generated SPM manifest consistent with the emitted XCFrameworks + onDegradeToSource: () => { + options.usePrebuiltExpo = false; + }, }); const productsPath = path.join(options.buildFolder, 'Build', 'Products'); @@ -233,6 +250,7 @@ export const packageIosCommand = curryOptions( explicitScheme: options.scheme, productsPath, configuration, + sdks, }); if (!frameworkName && options.addSpmPackage) { @@ -251,24 +269,19 @@ export const packageIosCommand = curryOptions( productsPath, configuration, frameworkName, + sdks, }); if (configuration.includes('Debug')) { // Re-merge only Debug frameworks so the simulator slice includes main.jsbundle. await mergeFrameworks({ sourceDir: userConfig.project.ios.sourceDir, - frameworkPaths: [ - path.join( - productsPath, - `${configuration}-iphoneos`, - `${frameworkName}.framework` - ), - path.join( - productsPath, - `${configuration}-iphonesimulator`, - `${frameworkName}.framework` - ), - ], + frameworkPaths: collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName, + }), outputPath: path.join(packageDir, `${frameworkName}.xcframework`), }); } @@ -300,20 +313,13 @@ export const packageIosCommand = curryOptions( await mergeFrameworks({ sourceDir: userConfig.project.ios.sourceDir, - frameworkPaths: [ - path.join( - productsPath, - `${configuration}-iphoneos`, - 'Brownie', - 'Brownie.framework' - ), - path.join( - productsPath, - `${configuration}-iphonesimulator`, - 'Brownie', - 'Brownie.framework' - ), - ], + frameworkPaths: collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName: 'Brownie', + productSubDir: 'Brownie', + }), outputPath: brownieOutputPath, }); @@ -335,20 +341,13 @@ export const packageIosCommand = curryOptions( await mergeFrameworks({ sourceDir: userConfig.project.ios.sourceDir, - frameworkPaths: [ - path.join( - productsPath, - `${configuration}-iphoneos`, - 'BrownfieldNavigation', - 'BrownfieldNavigation.framework' - ), - path.join( - productsPath, - `${configuration}-iphonesimulator`, - 'BrownfieldNavigation', - 'BrownfieldNavigation.framework' - ), - ], + frameworkPaths: collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName: 'BrownfieldNavigation', + productSubDir: 'BrownfieldNavigation', + }), outputPath: brownfieldNavigationOutputPath, }); @@ -378,9 +377,20 @@ export const packageIosCommand = curryOptions( 'In Xcode, choose File > Add Package Dependencies..., click Add Local..., and select that folder.' ); } + + // The dependency prints its own "Success" before the post-build steps above, + // so log a final line marking the true end of the run. + logger.success( + `package:ios finished. Artifacts are in ${colorLink(relativeToCwd(packageDir))}` + ); }) ); +/** Whether `--use-prebuilt-expo` was chosen (CLI or brownfield.config.json) rather than inferred. */ +export function isUsePrebuiltExpoExplicit(value: boolean | undefined) { + return value !== undefined; +} + export const packageIosExample = new ExampleUsage( 'package:ios --scheme BrownfieldLib --configuration Release', "Build iOS XCFramework for 'BrownfieldLib' scheme in Release configuration" diff --git a/packages/cli/src/brownfield/utils/__tests__/destination-sdks.test.ts b/packages/cli/src/brownfield/utils/__tests__/destination-sdks.test.ts new file mode 100644 index 00000000..67730440 --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/destination-sdks.test.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + collectFrameworkPaths, + hasFrameworkBuildProduct, + resolveDestinationSdks, +} from '../destinationSdks.js'; + +describe('resolveDestinationSdks', () => { + it('builds both slices when no destination is passed', () => { + expect(resolveDestinationSdks(undefined)).toEqual([ + 'iphoneos', + 'iphonesimulator', + ]); + expect(resolveDestinationSdks([])).toEqual([ + 'iphoneos', + 'iphonesimulator', + ]); + }); + + it('maps the device and simulator aliases', () => { + expect(resolveDestinationSdks(['simulator'])).toEqual(['iphonesimulator']); + expect(resolveDestinationSdks(['device'])).toEqual(['iphoneos']); + }); + + it('maps raw xcodebuild destinations', () => { + expect(resolveDestinationSdks(['generic/platform=iOS Simulator'])).toEqual([ + 'iphonesimulator', + ]); + expect(resolveDestinationSdks(['generic/platform=iOS'])).toEqual([ + 'iphoneos', + ]); + }); + + it('widens to both slices for destinations it cannot classify', () => { + expect(resolveDestinationSdks(['id=00008030-ABCDEF'])).toEqual([ + 'iphoneos', + 'iphonesimulator', + ]); + }); + + it('dedupes and keeps a stable order', () => { + expect(resolveDestinationSdks(['simulator', 'device', 'simulator'])).toEqual( + ['iphoneos', 'iphonesimulator'] + ); + }); +}); + +describe('collectFrameworkPaths', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'destination-sdks-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createFramework(sdk: string, subDir?: string, name = 'BrownfieldLib') { + const frameworkPath = path.join( + tempDir, + `Release-${sdk}`, + ...(subDir ? [subDir] : []), + `${name}.framework` + ); + fs.mkdirSync(frameworkPath, { recursive: true }); + return frameworkPath; + } + + it('returns only the slices that were built', () => { + const simulatorFramework = createFramework('iphonesimulator'); + + expect( + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphonesimulator'], + frameworkName: 'BrownfieldLib', + }) + ).toEqual([simulatorFramework]); + }); + + it('drops slices with no build product on disk', () => { + const simulatorFramework = createFramework('iphonesimulator'); + + expect( + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphoneos', 'iphonesimulator'], + frameworkName: 'BrownfieldLib', + }) + ).toEqual([simulatorFramework]); + }); + + it('accepts a static library as a build product', () => { + const productsDir = path.join(tempDir, 'Release-iphonesimulator'); + fs.mkdirSync(productsDir, { recursive: true }); + fs.writeFileSync(path.join(productsDir, 'libBrownfieldLib.a'), 'archive'); + + expect(hasFrameworkBuildProduct(productsDir, 'BrownfieldLib')).toBe(true); + expect( + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphonesimulator'], + frameworkName: 'BrownfieldLib', + }) + ).toEqual([path.join(productsDir, 'BrownfieldLib.framework')]); + }); + + it('throws a descriptive error when no slice has a build product', () => { + fs.mkdirSync(path.join(tempDir, 'Debug-iphonesimulator'), { + recursive: true, + }); + + expect(() => + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Debug', + sdks: ['iphonesimulator'], + frameworkName: 'mastodonreactnative', + }) + ).toThrowError(/Could not find a build product for mastodonreactnative/); + }); + + it('resolves nested pod products', () => { + const brownie = createFramework('iphonesimulator', 'Brownie', 'Brownie'); + + expect( + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphoneos', 'iphonesimulator'], + frameworkName: 'Brownie', + productSubDir: 'Brownie', + }) + ).toEqual([brownie]); + }); +}); diff --git a/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts b/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts index a12137b4..12b70f55 100644 --- a/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts @@ -20,6 +20,7 @@ vi.mock('../project.js', async (importOriginal) => { return { ...actual, getExpoSdkMajor: vi.fn(), + getExpoIosUsePrecompiledModules: vi.fn(), isExpoProject: vi.fn(), }; }); @@ -78,6 +79,9 @@ describe('emitExpoSupportXcframeworks', () => { fs.mkdirSync(projectRoot, { recursive: true }); fs.mkdirSync(packageDir, { recursive: true }); vi.clearAllMocks(); + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReturnValue( + undefined + ); }); afterEach(() => { @@ -241,4 +245,72 @@ describe('emitExpoSupportXcframeworks', () => { expect(fs.readdirSync(packageDir)).toEqual([]); expect(childProcess.execFileSync).not.toHaveBeenCalled(); }); + + it('names both remedies and the usePrecompiledModules cause when prebuilts are missing', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReturnValue( + false + ); + + createAllRequiredExpoSupportXcframeworks(projectRoot); + fs.rmSync(path.join(projectRoot, 'ios', 'Pods', 'ExpoFont'), { + recursive: true, + force: true, + }); + + const run = () => + emitExpoSupportXcframeworks({ + projectRoot, + packageDir, + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: true, + }); + expect(run).toThrow(/--use-prebuilt-expo false/); + expect(run).toThrow(/pod install/); + expect(run).toThrow(/ios\.usePrecompiledModules to false/); + }); + + it('degrades to source builds instead of throwing when usePrebuiltExpo was inferred', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + + createSignedMockXcframework( + path.join(projectRoot, 'node_modules', 'expo-modules-jsi', 'apple', 'Products'), + 'ExpoModulesJSI' + ); + const onDegradeToSource = vi.fn(); + + expect( + emitExpoSupportXcframeworks({ + projectRoot, + packageDir, + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: false, + onDegradeToSource, + }) + ).toBe(true); + + expect(onDegradeToSource).toHaveBeenCalledTimes(1); + expect(fs.readdirSync(packageDir)).toEqual(['ExpoModulesJSI.xcframework']); + }); + + it('still throws when usePrebuiltExpo was explicit and prebuilts are missing', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + + createSignedMockXcframework( + path.join(projectRoot, 'node_modules', 'expo-modules-jsi', 'apple', 'Products'), + 'ExpoModulesJSI' + ); + + expect(() => + emitExpoSupportXcframeworks({ + projectRoot, + packageDir, + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: true, + }) + ).toThrow(/Expected Expo SDK 56\+ XCFramework not found/); + }); }); diff --git a/packages/cli/src/brownfield/utils/__tests__/project.test.ts b/packages/cli/src/brownfield/utils/__tests__/project.test.ts index e806c614..15905e6e 100644 --- a/packages/cli/src/brownfield/utils/__tests__/project.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/project.test.ts @@ -4,7 +4,10 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { getExpoConfigIfIsExpo } from '../project.js'; +import { + getExpoConfigIfIsExpo, + getExpoIosUsePrecompiledModules, +} from '../project.js'; function createTempExpoProject(): string { const tempDir = fs.mkdtempSync( @@ -93,3 +96,74 @@ describe('getExpoConfigIfIsExpo', () => { ); }); }); + +function createDynamicConfigExpoProject(usePrecompiledModules: boolean): string { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'brownfield-expo-dynamic-') + ); + + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify( + { name: 'temp-dynamic-expo', version: '1.0.0', dependencies: { expo: '58.0.0' } }, + null, + 2 + ) + ); + + // Dynamic config only: no app.json, so `rootConfig` carries no plugins array. + fs.writeFileSync( + path.join(tempDir, 'app.config.js'), + `module.exports = { + name: 'TempDynamicExpo', + slug: 'temp-dynamic-expo', + plugins: [['expo-build-properties', { ios: { usePrecompiledModules: ${usePrecompiledModules} } }]], +}; +` + ); + + // Resolvable no-op plugin so plugin evaluation succeeds. + const pluginDir = path.join(tempDir, 'node_modules', 'expo-build-properties'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify( + { name: 'expo-build-properties', version: '1.0.0', main: 'index.js' }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(pluginDir, 'index.js'), + 'module.exports = (config) => config;\n' + ); + + return tempDir; +} + +describe('getExpoIosUsePrecompiledModules', () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('reads the value from a dynamic app.config.js', () => { + tempDir = createDynamicConfigExpoProject(false); + + expect(getExpoIosUsePrecompiledModules(tempDir as string)).toBe(false); + }); + + it('returns undefined rather than throwing when plugins cannot be evaluated', () => { + // createTempExpoProject lists a plugin that fails to load, which is the + // reason `skipPlugins` is used; the retry must degrade, not crash. + tempDir = createTempExpoProject(); + + expect(() => + getExpoIosUsePrecompiledModules(tempDir as string) + ).not.toThrow(); + }); +}); 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 index a9baea3f..a58f0c01 100644 --- 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 @@ -40,6 +40,22 @@ describe('resolvePackagedFrameworkName', () => { }); }); + it('resolves the packaged framework from a simulator-only build', () => { + const simulatorProductsPath = path.join(tempDir, 'Release-iphonesimulator'); + createFramework(simulatorProductsPath, 'BrownfieldLib', true); + + expect( + resolvePackagedFrameworkName({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphonesimulator'], + }) + ).toEqual({ + frameworkName: 'BrownfieldLib', + resolution: 'detected', + }); + }); + it('resolves the packaged framework automatically from the device build output', () => { const deviceProductsPath = path.join(tempDir, 'Debug-iphoneos'); createFramework(deviceProductsPath, 'BrownfieldLib', true); diff --git a/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts index 9f524adf..752a86c4 100644 --- a/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts @@ -8,6 +8,7 @@ vi.mock('../project.js', async (importOriginal) => { return { ...actual, getExpoSdkMajor: vi.fn(), + getExpoIosUsePrecompiledModules: vi.fn(), isExpoProject: vi.fn(), }; }); @@ -16,6 +17,7 @@ describe('supportsPrebuiltExpo', () => { beforeEach(() => { vi.mocked(projectUtils.isExpoProject).mockReset(); vi.mocked(projectUtils.getExpoSdkMajor).mockReset(); + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReset(); }); test('returns supported with opt-in default for non-Expo projects', () => { @@ -60,4 +62,32 @@ describe('supportsPrebuiltExpo', () => { reason: expect.stringMatching(/Expo SDK unknown/), }); }); + + test('returns unsupported when ios.usePrecompiledModules is false', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReturnValue( + false + ); + + expect(supportsPrebuiltExpo({ projectRoot: '/project' })).toEqual({ + supported: false, + reason: expect.stringMatching(/ios\.usePrecompiledModules/), + }); + }); + + test('stays supported when ios.usePrecompiledModules is true or unset', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + + for (const value of [true, undefined]) { + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReturnValue( + value + ); + expect(supportsPrebuiltExpo({ projectRoot: '/project' })).toEqual({ + supported: true, + enabledByDefault: true, + }); + } + }); }); diff --git a/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts b/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts index bf9c719d..b097c503 100644 --- a/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts +++ b/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts @@ -3,21 +3,31 @@ import path from 'node:path'; import { colorLink, logger, relativeToCwd } from '@rock-js/tools'; +import { ALL_APPLE_SDKS, type AppleSdk } from './destinationSdks.js'; + interface CopyDebugBundleToSimulatorSliceOptions { productsPath: string; configuration: string; frameworkName: string; + /** Slices the build produced; defaults to both. */ + sdks?: AppleSdk[]; } export function copyDebugBundleToSimulatorSlice({ productsPath, configuration, frameworkName, + sdks = ALL_APPLE_SDKS, }: CopyDebugBundleToSimulatorSliceOptions) { if (!configuration.includes('Debug')) { return; } + // nothing to copy across when only one slice was built + if (!sdks.includes('iphoneos') || !sdks.includes('iphonesimulator')) { + return; + } + const deviceBundlePath = path.join( productsPath, `${configuration}-iphoneos`, diff --git a/packages/cli/src/brownfield/utils/destinationSdks.ts b/packages/cli/src/brownfield/utils/destinationSdks.ts new file mode 100644 index 00000000..f6b5f17c --- /dev/null +++ b/packages/cli/src/brownfield/utils/destinationSdks.ts @@ -0,0 +1,122 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { RockError } from '@rock-js/tools'; + +/** Xcode SDK a build product directory belongs to (`Release-iphoneos`, `Release-iphonesimulator`). */ +export type AppleSdk = 'iphoneos' | 'iphonesimulator'; + +/** Both slices, in the order Rock's `packageIosAction` builds them. */ +export const ALL_APPLE_SDKS: AppleSdk[] = ['iphoneos', 'iphonesimulator']; + +/** + * Map a single `--destination` value onto the SDK(s) its build products land in. + * + * Mirrors `resolveDestination` in `@rock-js/platform-apple-helpers`: the aliases + * `device`/`simulator` plus any raw `xcodebuild -destination` string. A value we + * cannot classify (`id=`, `name=iPhone 17`) may resolve to either SDK, so it + * widens to both rather than silently dropping a slice. + */ +function resolveSdksForDestination(destination: string): AppleSdk[] { + const normalized = destination.trim().toLowerCase(); + + if (normalized === 'device') { + return ['iphoneos']; + } + + if (normalized === 'simulator' || normalized.includes('simulator')) { + return ['iphonesimulator']; + } + + if (normalized.includes('platform=ios')) { + return ['iphoneos']; + } + + return ALL_APPLE_SDKS; +} + +/** + * SDK slices a `package:ios` run produces for the given `--destination` values. + * + * Omitting `--destination` builds both slices, matching Rock's default. + */ +export function resolveDestinationSdks( + destination?: string[] | null +): AppleSdk[] { + if (!destination || destination.length === 0) { + return ALL_APPLE_SDKS; + } + + const sdks = new Set(destination.flatMap(resolveSdksForDestination)); + + // keep a stable order regardless of the order the flags were passed in + return ALL_APPLE_SDKS.filter((sdk) => sdks.has(sdk)); +} + +/** + * Whether `directoryPath` holds a build product `mergeFrameworks` can consume: + * either a real `.framework` bundle or the static library it synthesizes one from. + */ +export function hasFrameworkBuildProduct( + directoryPath: string, + frameworkName: string +) { + return ( + fs.existsSync(path.join(directoryPath, `${frameworkName}.framework`)) || + fs.existsSync(path.join(directoryPath, `lib${frameworkName}.a`)) + ); +} + +interface CollectFrameworkPathsOptions { + productsPath: string; + configuration: string; + sdks: AppleSdk[]; + frameworkName: string; + /** Sub-directory of the configuration products dir, e.g. `Brownie` for a Pod target. */ + productSubDir?: string; +} + +/** + * `.framework` paths to merge into an XCFramework, limited to the slices that were + * actually built. Slices with no build product on disk are dropped, so a + * `--destination simulator` run merges the simulator slice alone instead of failing + * on a missing `-iphoneos` directory. + * + * Throws when no slice has a build product: passing an empty list to + * `mergeFrameworks` would surface as xcodebuild's opaque + * "at least one framework or library must be specified". + */ +export function collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName, + productSubDir, +}: CollectFrameworkPathsOptions): string[] { + const searchedDirectories = sdks.map((sdk) => + path.join( + productsPath, + `${configuration}-${sdk}`, + ...(productSubDir ? [productSubDir] : []) + ) + ); + + const frameworkPaths = searchedDirectories + .filter((directoryPath) => + hasFrameworkBuildProduct(directoryPath, frameworkName) + ) + .map((directoryPath) => + path.join(directoryPath, `${frameworkName}.framework`) + ); + + if (frameworkPaths.length === 0) { + throw new RockError( + `Could not find a build product for ${frameworkName} in the ${configuration} configuration. ` + + `Looked for ${frameworkName}.framework or lib${frameworkName}.a in:\n` + + searchedDirectories.map((dir) => ` - ${dir}`).join('\n') + + `\nIf the build produced an .app instead of a framework, the wrong scheme was built; pass --scheme with your brownfield framework scheme.` + ); + } + + return frameworkPaths; +} diff --git a/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts b/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts index 93698602..42bfa52c 100644 --- a/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts +++ b/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts @@ -1,10 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; -import { RockError } from '@rock-js/tools'; +import { logger, RockError } from '@rock-js/tools'; import { normalizeCopiedXcframework } from './normalizeCopiedXcframework.js'; -import { getExpoSdkMajor, isExpoProject } from './project.js'; +import { + getExpoIosUsePrecompiledModules, + getExpoSdkMajor, + isExpoProject, +} from './project.js'; import { MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO } from './supportsPrebuiltExpo.js'; @@ -87,14 +91,32 @@ function resolveExpoFrameworkSourcePath( throw new RockError(`Unsupported Expo XCFramework: ${frameworkName}`); } +function getPrebuiltExpoRemedy(projectRoot: string) { + const cause = + getExpoIosUsePrecompiledModules(projectRoot) === false + ? 'expo-build-properties sets ios.usePrecompiledModules to false, so prebuilt Expo XCFrameworks are never produced. ' + : ''; + return `${cause}Either re-run with --use-prebuilt-expo false to build Expo modules from source, or run \`pod install\` (with ios.usePrecompiledModules enabled) to materialize the prebuilts.`; +} + export function emitExpoSupportXcframeworks({ projectRoot, packageDir, usePrebuiltExpo = true, + usePrebuiltExpoExplicit = true, + onDegradeToSource, }: { projectRoot: string; packageDir: string; usePrebuiltExpo?: boolean; + /** + * Whether `usePrebuiltExpo` was chosen by the user (CLI flag or brownfield.config.json) + * rather than inferred from the Expo SDK version. When inferred, missing prebuilts + * degrade to building Expo modules from source instead of throwing. + */ + usePrebuiltExpoExplicit?: boolean; + /** Called when an inferred `usePrebuiltExpo` was degraded to `false` because prebuilts are absent */ + onDegradeToSource?: () => void; }) { if (!isExpoProject(projectRoot)) { return false; @@ -108,6 +130,22 @@ export function emitExpoSupportXcframeworks({ return false; } + if (usePrebuiltExpo && !usePrebuiltExpoExplicit) { + const missingPrebuilts = ALL_EXPO_SUPPORT_XCFRAMEWORK_NAMES.filter( + (frameworkName) => + !fs.existsSync(resolveExpoFrameworkSourcePath(projectRoot, frameworkName)) + ); + // ExpoModulesJSI ships in node_modules and is not a Pods prebuilt; only degrade + // when Pods-provided prebuilts are absent. + if (missingPrebuilts.some((name) => name !== 'ExpoModulesJSI')) { + logger.warn( + `Prebuilt Expo XCFrameworks were not found (${missingPrebuilts.join(', ')}). ${getPrebuiltExpoRemedy(projectRoot)} Continuing without prebuilt Expo (--use-prebuilt-expo false); pass --use-prebuilt-expo true to make this an error.` + ); + usePrebuiltExpo = false; + onDegradeToSource?.(); + } + } + for (const frameworkName of getExpoSupportXcframeworkNames(usePrebuiltExpo)) { const sourcePath = resolveExpoFrameworkSourcePath( projectRoot, @@ -115,7 +153,7 @@ export function emitExpoSupportXcframeworks({ ); if (!fs.existsSync(sourcePath)) { throw new RockError( - `Expected Expo SDK ${MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO}+ XCFramework not found: ${frameworkName}.xcframework at ${path.relative(projectRoot, sourcePath)}` + `Expected Expo SDK ${MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO}+ XCFramework not found: ${frameworkName}.xcframework at ${path.relative(projectRoot, sourcePath)}. ${getPrebuiltExpoRemedy(projectRoot)}` ); } diff --git a/packages/cli/src/brownfield/utils/project.ts b/packages/cli/src/brownfield/utils/project.ts index 506e77e2..19aafc50 100644 --- a/packages/cli/src/brownfield/utils/project.ts +++ b/packages/cli/src/brownfield/utils/project.ts @@ -81,6 +81,71 @@ export function isExpoProject(projectRoot: string): boolean { return hasExpoAppConfig(projectRoot) && projectDependsOnExpo(projectRoot); } +type ExpoPluginEntry = string | [string, ...unknown[]] | unknown; + +/** + * Reads `ios.usePrecompiledModules` from the `expo-build-properties` plugin entry. + * + * `getExpoConfigIfIsExpo` uses `skipPlugins: true`, which makes `@expo/config` + * delete `exp.plugins`, so the plugins array is read from `rootConfig` (the raw + * static app.json / app.config.json) instead. `rootConfig` is the raw *static* + * config only, so a dynamic config (app.config.js/ts) exposes no plugins that + * way; for those we retry once with plugins enabled, which keeps the plugins + * array. That retry can throw for plugins that are not loadable as Node modules + * (the reason `skipPlugins` is set in the first place), so it is best-effort. + * + * @returns the explicit boolean value, or `undefined` if it is not set or not resolvable + */ +export function getExpoIosUsePrecompiledModules( + projectRoot: string +): boolean | undefined { + const config = getExpoConfigIfIsExpo(projectRoot); + if (!config) { + return undefined; + } + + const rootConfig = config.rootConfig as + | { plugins?: ExpoPluginEntry[]; expo?: { plugins?: ExpoPluginEntry[] } } + | undefined; + let plugins = + (config.exp as { plugins?: ExpoPluginEntry[] }).plugins ?? + rootConfig?.expo?.plugins ?? + rootConfig?.plugins; + + if (!Array.isArray(plugins)) { + // Dynamic config: no static plugins array exists. Retry with plugins + // enabled, which preserves `exp.plugins`. Best-effort — plugin resolution + // can throw, and an unreadable config is treated the same as an unset value. + try { + plugins = ( + getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp as { + plugins?: ExpoPluginEntry[]; + } + ).plugins; + } catch { + return undefined; + } + } + + if (!Array.isArray(plugins)) { + return undefined; + } + + for (const plugin of plugins) { + if (!Array.isArray(plugin) || plugin[0] !== 'expo-build-properties') { + continue; + } + const value = ( + plugin[1] as { ios?: { usePrecompiledModules?: unknown } } | undefined + )?.ios?.usePrecompiledModules; + if (typeof value === 'boolean') { + return value; + } + } + + return undefined; +} + export function getExpoSdkMajor(projectRoot: string): number | null { const rawExpoVersion = getExpoConfigIfIsExpo(projectRoot)?.exp.sdkVersion; if (!rawExpoVersion) { diff --git a/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts b/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts index 4213742f..29083683 100644 --- a/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts +++ b/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import { ALL_APPLE_SDKS, type AppleSdk } from './destinationSdks.js'; + type Resolution = 'explicit' | 'detected' | 'not_found' | 'ambiguous'; export interface ResolvePackagedFrameworkNameResult { @@ -13,6 +15,8 @@ interface ResolvePackagedFrameworkNameOptions { explicitScheme?: string; productsPath: string; configuration: string; + /** Slices the build produced; defaults to both. */ + sdks?: AppleSdk[]; } function collectFrameworkCandidates(configurationProductsPath: string): string[] { @@ -61,6 +65,7 @@ export function resolvePackagedFrameworkName({ explicitScheme, productsPath, configuration, + sdks = ALL_APPLE_SDKS, }: ResolvePackagedFrameworkNameOptions): ResolvePackagedFrameworkNameResult { if (explicitScheme) { return { @@ -69,11 +74,17 @@ export function resolvePackagedFrameworkName({ }; } - const configurationProductsPath = path.join( - productsPath, - `${configuration}-iphoneos` - ); - const candidates = collectFrameworkCandidates(configurationProductsPath); + // the JS bundle is emitted per slice, so scan every slice that was built rather + // than assuming the device one exists (`--destination simulator` skips it) + const candidates = [ + ...new Set( + sdks.flatMap((sdk) => + collectFrameworkCandidates( + path.join(productsPath, `${configuration}-${sdk}`) + ) + ) + ), + ].sort(); if (candidates.length === 1) { return { diff --git a/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts b/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts index 38cc1c51..621216e0 100644 --- a/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts +++ b/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts @@ -1,4 +1,8 @@ -import { getExpoSdkMajor, isExpoProject } from './project.js'; +import { + getExpoIosUsePrecompiledModules, + getExpoSdkMajor, + isExpoProject, +} from './project.js'; export const MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO = 56; @@ -27,6 +31,14 @@ export function supportsPrebuiltExpo({ }; } + if (getExpoIosUsePrecompiledModules(projectRoot) === false) { + return { + supported: false, + reason: + "--use-prebuilt-expo is unsupported: expo-build-properties sets ios.usePrecompiledModules to false in your Expo config, so prebuilt Expo XCFrameworks are never produced. Set it to true (or remove it) and run 'pod install', or pass --use-prebuilt-expo false to build Expo modules from source.", + }; + } + return { supported: true, enabledByDefault: true, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 867a3dbb..bdeefc1c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,13 @@ import navigationCommands, { groupName as navigationCommandsGroupName, } from './navigation/index.js'; +// CocoaPods (spawned by Rock) raises Encoding::CompatibilityError under a non-UTF-8 +// locale. Default to UTF-8 only when the inherited value is missing or non-UTF-8. +if (!/utf-?8/i.test(process.env.LC_ALL || process.env.LANG || '')) { + process.env.LANG = 'en_US.UTF-8'; + process.env.LC_ALL = 'en_US.UTF-8'; +} + const program = new Command(); program diff --git a/yarn.lock b/yarn.lock index 61002589..7453c5a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1843,7 +1843,7 @@ __metadata: "@rock-js/platform-android": "npm:^0.14.0" "@rock-js/platform-apple-helpers": "npm:^0.14.0" "@rock-js/plugin-brownfield-android": "npm:^0.14.0" - "@rock-js/plugin-brownfield-ios": "npm:^0.14.0" + "@rock-js/plugin-brownfield-ios": "patch:@rock-js/plugin-brownfield-ios@npm%3A0.14.0#~/.yarn/patches/@rock-js-plugin-brownfield-ios-npm-0.14.0-0152061a4e.patch" "@rock-js/tools": "npm:^0.14.0" "@types/babel__core": "npm:^7.20.5" "@types/babel__preset-env": "npm:^7.10.0" @@ -6521,7 +6521,7 @@ __metadata: languageName: node linkType: hard -"@rock-js/plugin-brownfield-ios@npm:^0.14.0": +"@rock-js/plugin-brownfield-ios@npm:0.14.0": version: 0.14.0 resolution: "@rock-js/plugin-brownfield-ios@npm:0.14.0" dependencies: @@ -6534,6 +6534,19 @@ __metadata: languageName: node linkType: hard +"@rock-js/plugin-brownfield-ios@patch:@rock-js/plugin-brownfield-ios@npm%3A0.14.0#~/.yarn/patches/@rock-js-plugin-brownfield-ios-npm-0.14.0-0152061a4e.patch": + version: 0.14.0 + resolution: "@rock-js/plugin-brownfield-ios@patch:@rock-js/plugin-brownfield-ios@npm%3A0.14.0#~/.yarn/patches/@rock-js-plugin-brownfield-ios-npm-0.14.0-0152061a4e.patch::version=0.14.0&hash=45e36f" + dependencies: + "@react-native-community/cli-config-apple": "npm:^20.0.0" + "@react-native-community/cli-types": "npm:^20.0.0" + "@rock-js/platform-apple-helpers": "npm:^0.14.0" + "@rock-js/tools": "npm:^0.14.0" + tslib: "npm:^2.3.0" + checksum: 10/7708735acaca21b2a3de53a05086b39372830951ad35e514539b371c71bcaf149b1549baa021cbe4bb570bfd278ed125f9f0d4e409ed796d894cb4dc68d95079 + languageName: node + linkType: hard + "@rock-js/tools@npm:^0.13.5": version: 0.13.5 resolution: "@rock-js/tools@npm:0.13.5"