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/fuzzy-owls-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@callstack/brownfield-cli': minor
---

Add `--add-spm-package` to `brownfield package:ios` so packaging can also generate a local Swift Package Manager wrapper around the produced XCFrameworks, including a generated `Package.swift`, `README.md`, and Xcode integration instructions. Fail fast when Debug packaging cannot resolve the app framework name while local SPM output is requested.
51 changes: 50 additions & 1 deletion docs/docs/docs/api-reference/brownie/xcframework-packaging.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Running `npx brownfield package:ios` produces the following XCFrameworks in `ios
- `Brownie.xcframework` - Brownie shared state library
- `hermesvm.xcframework` (or `hermes.xcframework` for RN < 0.82.0)
- `ReactBrownfield.xcframework`
- `React.xcframework` and `ReactNativeDependencies.xcframework` when React Native prebuilts are enabled
- `Package.swift` when `--add-spm-package` is used

The consumer app needs to embed all 4 frameworks when using Brownie.

Expand Down Expand Up @@ -37,7 +39,54 @@ Note: This command also takes care of running `brownfield codegen` for you.

### Swift Package Manager

You can also distribute the XCFrameworks via SPM by creating a binary target package. See [XCFramework Packaging](/docs/cli/brownfield) for details.
You can also distribute the XCFrameworks via a local Swift Package Manager package generated by the CLI:

```bash
npx brownfield package:ios --scheme BrownfieldLib --configuration Release --add-spm-package
```

This writes `Package.swift` into `ios/.brownfield/package/build/`. In Xcode, choose **File > Add Package Dependencies...**, click **Add Local...**, and select that folder.

### Host App Integration

After adding the generated local package to your native iOS project:

1. Select your host app target
2. Open the **General** tab
3. Under **Frameworks, Libraries, and Embedded Content**, add the package product named after your packaged framework, for example `BrownfieldLib`

Use one integration mode at a time:

- If you use the generated local Swift package, remove old direct `*.xcframework` links from the host app target
- If you keep the old direct `*.xcframework` links, do not also wire in the package product

The generated local package folder also includes a `README.md` file so Xcode can show package-specific usage notes in the package browser.

### Verification Checklist

To verify the local Swift package flow works as expected:

1. Package the React Native app:

```bash
npx brownfield package:ios --scheme BrownfieldLib --configuration Release --add-spm-package
```

2. Confirm the generated folder contains:
- `Package.swift`
- `README.md`
- `BrownfieldLib.xcframework`
- `hermesvm.xcframework` or `hermes.xcframework`
- `ReactBrownfield.xcframework`
- optional frameworks such as `Brownie.xcframework`, `BrownfieldNavigation.xcframework`, `React.xcframework`, and `ReactNativeDependencies.xcframework` when they are emitted for your app

3. Add the generated folder to the host app with **File > Add Package Dependencies...** and **Add Local...**
4. Add the package product, for example `BrownfieldLib`, to the host app target
5. Remove old direct `*.xcframework` links from the host app target if they are still present
6. Build the host app
7. Run the host app without Metro when validating a Release package

In this repository, `apps/RNApp` is the packaged React Native app and `apps/AppleApp` is the example native iOS host app you can use to validate the consumer integration.

## Importing Brownie

Expand Down
5 changes: 5 additions & 0 deletions docs/docs/docs/cli/brownfield.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Available arguments:
| --build-folder | Location for iOS build artifacts. Corresponds to Xcode's "-derivedDataPath". By default, the '\<iOS project folder>/.brownfield/build' path will be used. |
| --destination | Define destination(s) for the build. You can pass multiple destinations as separate values or repeated use of the flag. Values: "simulator", "device", or xcodebuild destinations |
| --archive | Create an Xcode archive (IPA) of the build, required for uploading to App Store Connect or distributing to TestFlight |
| --add-spm-package | Generate a local `Package.swift` next to the packaged XCFramework outputs so the folder can be added to Xcode as a local Swift Package |
| --use-prebuilt-rn-core [bool] | Controls usage of React Native Apple prebuilt binaries for the packaging Xcode build. Omit for version-aware defaults (see [Getting Started — iOS — React Native Prebuilts](/docs/getting-started/ios#react-native-prebuilts)). Pass `true`, `false`, or use the flag without a value as shorthand for `true`. Supported only for Expo 55+ OR vanilla RN >= 0.81. |
| --no-install-pods | Skip automatic CocoaPods installation |
| --no-new-arch | Run React Native in legacy async architecture |
Expand All @@ -45,6 +46,8 @@ The build directory will be placed in the `<iOS project folder>/.brownfield/buil
- `package/build/ReactBrownfield.xcframework`
- `package/build/Brownie.xcframework` (only when using the Brownie package)
- `package/build/BrownfieldNavigation.xcframework` (only when using the Navigation package)
- `package/build/React.xcframework` and `package/build/ReactNativeDependencies.xcframework` (when packaging with React Native prebuilts enabled)
- `package/build/Package.swift` (only when `--add-spm-package` is used)

The consumer project needs to embed the required frameworks:

Expand All @@ -55,6 +58,8 @@ The consumer project needs to embed the required frameworks:
If you are using the Brownie package, you will also need to embed `Brownie.xcframework`.
If you are using the brownfield-navigation package, you will also need to embed `BrownfieldNavigation.xcframework`.

If you pass `--add-spm-package`, the CLI also writes `Package.swift` into the same `package/build` directory and prints the folder path in the terminal. In Xcode, use **File > Add Package Dependencies...**, click **Add Local...**, and select that `package/build` folder to consume the generated XCFrameworks as a local Swift package instead of dragging them in manually.

## Android

For Android, building happens in two steps: first, you build (`brownfield package:android`) the AAR artifact(s) with your module, in the appropriate build variant(s), and then you `brownfield publish:android` them to Maven local.
Expand Down
33 changes: 33 additions & 0 deletions docs/docs/docs/getting-started/ios.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ npx brownfield package:ios --scheme <framework_target_name> --configuration Rele

This creates the XCFramework in **`ios/.brownfield/package/build/`** (relative to your project root).

If you also want a local Swift Package Manager wrapper around the generated XCFrameworks, add `--add-spm-package`:

```bash
npx brownfield package:ios --scheme <framework_target_name> --configuration Release --add-spm-package
```

That command writes `Package.swift` into the same `ios/.brownfield/package/build/` directory.

## 6. Add the Framework to Your iOS App

1. Open **`ios/.brownfield/package/build`** directory (relative to your React Native project root)
Expand All @@ -128,6 +136,31 @@ This creates the XCFramework in **`ios/.brownfield/package/build/`** (relative t

![Frameworks in Xcode Sidebar](/images/frameworks.png)

### Optional: Add as a Local Swift Package

If you ran `package:ios` with `--add-spm-package`, you can add the generated folder as a local Swift package instead of dragging XCFrameworks manually:

1. In Xcode, choose **File > Add Package Dependencies...**
2. Click **Add Local...**
3. Select `ios/.brownfield/package/build/`

The generated package references the packaged app XCFramework, Hermes, `ReactBrownfield`, and optional frameworks such as `Brownie`, `BrownfieldNavigation`, `React`, and `ReactNativeDependencies` when they exist in that folder.

After adding the local package:

1. Select your host app target
2. Open the **General** tab
3. Under **Frameworks, Libraries, and Embedded Content**, add the package product named after your packaged framework target, for example `<framework_target_name>`
4. Remove any old direct `*.xcframework` entries from the target if you are switching from manual drag-and-drop to the local package flow

For a successful validation of a Release package:

1. Build the React Native package with `--add-spm-package`
2. Add the generated local package to the host app
3. Add the package product to the host app target
4. Build the host app
5. Run the host app without Metro and confirm the packaged React Native content loads correctly

## 7. Initialize React Native

In your native iOS app's **`AppDelegate.swift`**:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import * as appleHelpers from '@rock-js/platform-apple-helpers';
import { packageIosAction } from '@rock-js/plugin-brownfield-ios';
import * as rockTools from '@rock-js/tools';

import { beforeEach, describe, expect, test, vi, type Mock } from 'vitest';

import { runBrownieCodegenIfApplicable } from '../../../brownie/helpers/runBrownieCodegenIfApplicable.js';
import { runNavigationCodegenIfApplicable } from '../../../navigation/helpers/runNavigationCodegenIfApplicable.js';
import { packageIosCommand } from '../packageIos.js';
import { copyDebugBundleToSimulatorSlice } from '../../utils/copyDebugBundleToSimulatorSlice.js';
import { createLocalSpmPackage } from '../../utils/createLocalSpmPackage.js';
import { runExpoPrebuildIfNeeded } from '../../utils/expo.js';
import { getProjectInfo } from '../../utils/project.js';
import { resolvePackagedFrameworkName } from '../../utils/resolvePackagedFrameworkName.js';
import { supportsPrebuiltRNCore } from '../../utils/supportsPrebuiltRNCore.js';

vi.mock('@rock-js/platform-apple-helpers', async (importOriginal) => {
const actual = await importOriginal<typeof appleHelpers>();
return {
...actual,
getBuildOptions: vi.fn(() => [
{
name: '--configuration <configuration>',
description: 'test configuration option',
},
{
name: '--scheme <scheme>',
description: 'test scheme option',
},
]),
mergeFrameworks: vi.fn(),
};
});

vi.mock('@rock-js/plugin-brownfield-ios', () => ({
packageIosAction: vi.fn(),
}));

vi.mock('@rock-js/tools', async (importOriginal) => {
const actual = await importOriginal<typeof rockTools>();
return {
...actual,
colorLink: vi.fn((value: string) => value),
getReactNativeVersion: vi.fn(() => '0.84.0'),
relativeToCwd: vi.fn((value: string) => value),
logger: {
...actual.logger,
error: vi.fn(),
info: vi.fn(),
isVerbose: vi.fn(() => false),
success: vi.fn(),
warn: vi.fn(),
},
};
});

vi.mock('../../utils/expo.js', () => ({
runExpoPrebuildIfNeeded: vi.fn(),
}));

vi.mock('../../utils/project.js', () => ({
getProjectInfo: vi.fn(() => ({
projectRoot: '/repo',
platformConfig: {},
userConfig: {
reactNativePath: '/repo/node_modules/react-native',
project: {
ios: {
sourceDir: 'ios',
xcodeProject: {
name: 'RNApp.xcodeproj',
},
},
},
},
})),
}));

vi.mock('../../utils/supportsPrebuiltRNCore.js', () => ({
supportsPrebuiltRNCore: vi.fn(() => ({
supported: true,
enabledByDefault: false,
reason: '',
})),
}));

vi.mock('../../../brownie/helpers/runBrownieCodegenIfApplicable.js', () => ({
runBrownieCodegenIfApplicable: vi.fn(async () => ({
hasBrownie: false,
})),
}));

vi.mock('../../../navigation/helpers/runNavigationCodegenIfApplicable.js', () => ({
runNavigationCodegenIfApplicable: vi.fn(async () => ({
hasNavigation: false,
})),
}));

vi.mock('../../utils/copyDebugBundleToSimulatorSlice.js', () => ({
copyDebugBundleToSimulatorSlice: vi.fn(),
}));

vi.mock('../../utils/resolvePackagedFrameworkName.js', () => ({
resolvePackagedFrameworkName: vi.fn(() => ({
frameworkName: 'BrownfieldLib',
resolution: 'explicit',
candidates: [],
})),
}));

vi.mock('../../utils/createLocalSpmPackage.js', () => ({
createLocalSpmPackage: vi.fn(() => ({
packageManifestPath: '/repo/ios/.brownfield/package/build/Package.swift',
})),
}));

const invokePackageIosAction = async (argv: string[]) => {
await packageIosCommand.parseAsync(argv, { from: 'user' });
};

// @ts-expect-error - override typings
const processExitMock = vi.spyOn(process, 'exit').mockImplementation(() => {
// no-op
});

const mockLoggerError = rockTools.logger.error as Mock;
const mockLoggerInfo = rockTools.logger.info as Mock;
const mockLoggerSuccess = rockTools.logger.success as Mock;
const mockLoggerWarn = rockTools.logger.warn as Mock;
const mockCreateLocalSpmPackage = createLocalSpmPackage as Mock;
const mockResolvePackagedFrameworkName = resolvePackagedFrameworkName as Mock;

describe('package:ios action --add-spm-package', () => {
beforeEach(() => {
vi.clearAllMocks();

mockResolvePackagedFrameworkName.mockReturnValue({
frameworkName: 'BrownfieldLib',
resolution: 'explicit',
candidates: [],
});
mockCreateLocalSpmPackage.mockReturnValue({
packageManifestPath: '/repo/ios/.brownfield/package/build/Package.swift',
});
});

test('calls createLocalSpmPackage with the resolved framework name', async () => {
await invokePackageIosAction(['--add-spm-package', '--configuration', 'Release']);

expect(packageIosAction).toHaveBeenCalledOnce();
expect(copyDebugBundleToSimulatorSlice).toHaveBeenCalledWith({
productsPath: '/repo/ios/.brownfield/build/Build/Products',
configuration: 'Release',
frameworkName: 'BrownfieldLib',
});
expect(mockCreateLocalSpmPackage).toHaveBeenCalledWith({
packageDir: '/repo/ios/.brownfield/package/build',
frameworkName: 'BrownfieldLib',
});
expect(mockLoggerSuccess).toHaveBeenCalledWith(
'Local SPM package manifest created at /repo/ios/.brownfield/package/build/Package.swift'
);
expect(mockLoggerInfo).toHaveBeenCalledWith(
'Add the local package folder in Xcode: /repo/ios/.brownfield/package/build'
);
});

test('passes undefined frameworkName when package:ios could not resolve it outside Debug mode', async () => {
mockResolvePackagedFrameworkName.mockReturnValue({
frameworkName: null,
resolution: 'not_found',
candidates: [],
});

await invokePackageIosAction(['--add-spm-package', '--configuration', 'Release']);

expect(mockCreateLocalSpmPackage).toHaveBeenCalledWith({
packageDir: '/repo/ios/.brownfield/package/build',
frameworkName: undefined,
});
expect(mockLoggerWarn).not.toHaveBeenCalled();
expect(processExitMock).not.toHaveBeenCalled();
});

test('fails fast when Debug packaging cannot resolve the framework name and SPM output was requested', async () => {
mockResolvePackagedFrameworkName.mockReturnValue({
frameworkName: null,
resolution: 'ambiguous',
candidates: ['AppOne', 'AppTwo'],
});

await invokePackageIosAction(['--add-spm-package', '--configuration', 'Debug']);

expect(mockCreateLocalSpmPackage).not.toHaveBeenCalled();
expect(mockLoggerWarn).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
'Cannot generate local SPM package: found multiple bundled framework candidates (AppOne, AppTwo); pass --scheme explicitly'
);
expect(processExitMock).toHaveBeenCalledWith(1);
});
});
Loading
Loading