Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-parrots-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@callstack/brownfield-cli': patch
---

respect `--destination` in `package:ios` when merging XCFrameworks
Original file line number Diff line number Diff line change
@@ -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=<udid>` / `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=<udid>` / `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',
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as rockTools from '@rock-js/tools';
import { describe, expect, test } from 'vitest';

import {
isUsePrebuiltExpoExplicit,
packageIosCommand,
parseUsePrebuiltExpoArgument,
parseUsePrebuiltRnCoreArgument,
Expand Down Expand Up @@ -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);
});
});
Loading
Loading