Skip to content
Open
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.
23 changes: 22 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ There are 2 brownfield host apps.
- `build:example:ios-consumer:expo55` (or `expo`) — target `Brownfield Apple App (ExpoApp55)`, scheme **Brownfield Apple App Expo 55**
- `build:example:ios-consumer:vanilla` — target `Brownfield Apple App (RNApp)`, scheme **Brownfield Apple App Vanilla**

For iOS, each script uses the previously packaged artifacts from the respective directory (`apps/RNApp`, `apps/ExpoApp54`, or `apps/ExpoApp55`), invokes `prepareXCFrameworks.js` to copy XCFrameworks into `apps/AppleApp/package`, then runs `xcodebuild` against the matching scheme. The Xcode project reads fixed paths under `package/` (for example `package/BrownfieldLib.xcframework`).
For iOS, these scripts validate the legacy direct-XCFramework integration path. Each script uses the previously packaged artifacts from the respective directory (`apps/RNApp`, `apps/ExpoApp54`, or `apps/ExpoApp55`), invokes `prepareXCFrameworks.js` to copy XCFrameworks into `apps/AppleApp/package`, then runs `xcodebuild` against the matching scheme. The Xcode project reads fixed paths under `package/` (for example `package/BrownfieldLib.xcframework`).

| Yarn script | RN app | Xcode target | Scheme | Configuration |
| --- | --- | --- | --- | --- |
Expand All @@ -74,6 +74,27 @@ For iOS, each script uses the previously packaged artifacts from the respective
> [!IMPORTANT]
> You can build and run `AppleApp` from the Xcode GUI by selecting the scheme for the variant you want. Before running, after switching schemes or re-packaging an RN app, run the matching `build:example:ios-consumer:...` script so fresh artifacts are present in `apps/AppleApp/package`. Otherwise Xcode will still link against the previous XCFrameworks.

### Running `AppleApp` with local SPM

The local Swift Package Manager flow is separate from `prepareXCFrameworks.js`. Instead of copying artifacts into `apps/AppleApp/package`, generate a local package next to the packaged RN app and add that package in Xcode.

1. Package the producer app with `--add-spm-package`, for example:
- `cd apps/RNApp && yarn exec brownfield package:ios --scheme BrownfieldLib --configuration Release --add-spm-package`
- `cd apps/ExpoApp55 && yarn exec brownfield package:ios --scheme BrownfieldLib --configuration Release --add-spm-package`
2. Open `apps/AppleApp/Brownfield Apple App.xcodeproj`.
3. Select the host scheme you want to validate:
- `Brownfield Apple App Vanilla`
- `Brownfield Apple App Expo 54`
- `Brownfield Apple App Expo 55`
4. In Xcode, go to `Package Dependencies`, click `+`, choose `Add Local...`, and select the generated package folder:
- `apps/RNApp/ios/.brownfield/package/build`
- `apps/ExpoApp54/ios/.brownfield/package/build`
- `apps/ExpoApp55/ios/.brownfield/package/build`
5. Add the `BrownfieldLib` product to the matching AppleApp target.
6. Remove old direct `package/*.xcframework` references from that target if you are switching from the legacy direct-XCFramework path.

AppleApp now derives its native shell label from target build settings and uses the shared brownfield React Native entry point directly, so the local SPM flow does not require `prepareXCFrameworks.js` to rewrite Swift source files before you build.

## Tests

The React Native example apps share Jest utilities and test suites from `apps/brownfield-example-shared-tests`. Tests exercise integration with `@callstack/react-native-brownfield`, `@callstack/brownfield-navigation`, and `@callstack/brownie` as used in each demo.
Expand Down
41 changes: 2 additions & 39 deletions apps/AppleApp/Brownfield Apple App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -105,53 +105,16 @@
79B1D99A2FB4A61400A5F42B /* BrownfieldNavigation.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BrownfieldNavigation.xcframework; path = package/BrownfieldNavigation.xcframework; sourceTree = "<group>"; };
79B1D99B2FB4A61400A5F42B /* Brownie.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Brownie.xcframework; path = package/Brownie.xcframework; sourceTree = "<group>"; };
79B1D99C2FB4A61400A5F42B /* hermesvm.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = hermesvm.xcframework; path = package/hermesvm.xcframework; sourceTree = "<group>"; };
79B1D99D2FB4A61400A5F42B /* React.xcframework */ = {isa = PBXFileReference; expectedSignature = "SelfSigned:BEEAE960B121B398BB228BD547298BD461DFFFD862BD3AD794A38F6D426F6741"; lastKnownFileType = wrapper.xcframework; name = React.xcframework; path = package/React.xcframework; sourceTree = "<group>"; };
79B1D99D2FB4A61400A5F42B /* React.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = React.xcframework; path = package/React.xcframework; sourceTree = "<group>"; };
79B1D99E2FB4A61400A5F42B /* ReactBrownfield.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReactBrownfield.xcframework; path = package/ReactBrownfield.xcframework; sourceTree = "<group>"; };
79B1D99F2FB4A61400A5F42B /* ReactNativeDependencies.xcframework */ = {isa = PBXFileReference; expectedSignature = "SelfSigned:BEEAE960B121B398BB228BD547298BD461DFFFD862BD3AD794A38F6D426F6741"; lastKnownFileType = wrapper.xcframework; name = ReactNativeDependencies.xcframework; path = package/ReactNativeDependencies.xcframework; sourceTree = "<group>"; };
79B1D99F2FB4A61400A5F42B /* ReactNativeDependencies.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ReactNativeDependencies.xcframework; path = package/ReactNativeDependencies.xcframework; sourceTree = "<group>"; };
79B8BE802FB7270E00B94C6F /* Brownfield Apple App (ExpoApp54).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Brownfield Apple App (ExpoApp54).app"; sourceTree = BUILT_PRODUCTS_DIR; };
79B8BE9B2FB7273600B94C6F /* Brownfield Apple App (ExpoApp55).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Brownfield Apple App (ExpoApp55).app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
79B8BE822FB7270F00B94C6F /* Exceptions for "Brownfield Apple App" folder in "Brownfield Apple App (ExpoApp54)" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Assets.xcassets,
BrownfieldAppleApp.swift,
components/ContentView.swift,
components/GreetingCard.swift,
components/MaterialCard.swift,
components/MessagesView.swift,
components/ReferralsScreen.swift,
components/SettingsScreen.swift,
components/Toast.swift,
);
target = 79B8BE682FB7270E00B94C6F /* Brownfield Apple App (ExpoApp54) */;
};
79B8BE9C2FB7273600B94C6F /* Exceptions for "Brownfield Apple App" folder in "Brownfield Apple App (ExpoApp55)" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Assets.xcassets,
BrownfieldAppleApp.swift,
components/ContentView.swift,
components/GreetingCard.swift,
components/MaterialCard.swift,
components/MessagesView.swift,
components/ReferralsScreen.swift,
components/SettingsScreen.swift,
components/Toast.swift,
);
target = 79B8BE832FB7273600B94C6F /* Brownfield Apple App (ExpoApp55) */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
793C76A92EEBF938008A2A34 /* Brownfield Apple App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
79B8BE822FB7270F00B94C6F /* Exceptions for "Brownfield Apple App" folder in "Brownfield Apple App (ExpoApp54)" target */,
79B8BE9C2FB7273600B94C6F /* Exceptions for "Brownfield Apple App" folder in "Brownfield Apple App (ExpoApp55)" target */,
);
path = "Brownfield Apple App";
sourceTree = "<group>";
};
Expand Down
14 changes: 12 additions & 2 deletions apps/AppleApp/Brownfield Apple App/components/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,27 @@ let initialState = BrownfieldStore(
user: User(name: "Username")
)

#if USE_EXPO_HOST
private let hostAppName = "iOS Expo"
#else
private let hostAppName = "iOS Vanilla"
#endif

// The packaged brownfield example surface is registered under `RNApp`
// for both the plain React Native and Expo example apps.
private let reactNativeModuleName = "RNApp"

struct ContentView: View {
var body: some View {
NavigationView {

VStack(spacing: 16) {
GreetingCard(name: "iOS Vanilla")
GreetingCard(name: hostAppName)

MessagesView()

ReactNativeView(
moduleName: "RNApp",
moduleName: reactNativeModuleName,
initialProperties: [
"nativeOsVersionLabel":
"\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)"
Expand Down
103 changes: 43 additions & 60 deletions apps/AppleApp/prepareXCFrameworks.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,6 @@ const sourcePackagePath = path.join(

const targetPackagePath = path.join(__dirname, 'package');

if (fs.existsSync(targetPackagePath)) {
logger.info(`Removing ${targetPackagePath}\n`);
fs.rmSync(targetPackagePath, { recursive: true });
}

logger.info(`Copying ${sourcePackagePath} to ${targetPackagePath}\n`);
fs.cpSync(sourcePackagePath, targetPackagePath, { recursive: true });

/**
* The Xcode project is configured to link the following frameworks:
* - BrownfieldLib (constant)
Expand All @@ -61,6 +53,49 @@ fs.cpSync(sourcePackagePath, targetPackagePath, { recursive: true });
*
*/

const validNames = [
'BrownfieldLib.xcframework',
'Brownie.xcframework',
'hermesvm.xcframework',
'ReactBrownfield.xcframework',
'BrownfieldNavigation.xcframework',
// below: optional, emitted when RN is packaged with prebuilt iOS pods
'React.xcframework',
'ReactNativeDependencies.xcframework',
];

if (fs.existsSync(targetPackagePath)) {
logger.info(`Removing ${targetPackagePath}\n`);
fs.rmSync(targetPackagePath, { recursive: true, force: true });
}

logger.info(
`Copying XCFrameworks from ${sourcePackagePath} to ${targetPackagePath}\n`
);
fs.mkdirSync(targetPackagePath, { recursive: true });

const spmArtifactsPath = path.join(sourcePackagePath, 'spm-artifacts');
const preferredArtifactSourcePath = fs.existsSync(spmArtifactsPath)
? spmArtifactsPath
: sourcePackagePath;

for (const file of fs.readdirSync(preferredArtifactSourcePath)) {
if (
!validNames.includes(file) &&
!['hermes.xcframework', 'hermesvm.xcframework'].includes(file)
) {
continue;
}

fs.cpSync(
path.join(preferredArtifactSourcePath, file),
path.join(targetPackagePath, file),
{
recursive: true,
}
);
}

// handle hermesvm.xcframework / hermes.xcframework
let hermesArtifactFound = false;
for (const candidateDir of ['hermes.xcframework', 'hermesvm.xcframework']) {
Expand All @@ -77,18 +112,6 @@ if (!hermesArtifactFound) {
throw new Error('Hermes artifact not found');
}

// list files
const validNames = [
'BrownfieldLib.xcframework',
'Brownie.xcframework',
'hermesvm.xcframework',
'ReactBrownfield.xcframework',
'BrownfieldNavigation.xcframework',
// below: optional, emitted when RN is packaged with prebuilt iOS pods
'React.xcframework',
'ReactNativeDependencies.xcframework',
];

for (const file of fs.readdirSync(targetPackagePath)) {
if (!validNames.includes(file)) {
throw new Error(`Invalid file name: ${file}`);
Expand All @@ -97,44 +120,4 @@ for (const file of fs.readdirSync(targetPackagePath)) {
logger.success(`${file} prepared`);
}

logger.info('Patching entrypoint name in ContentView.swift');
const filePath = path.join(
__dirname,
'Brownfield Apple App',
'components',
'ContentView.swift'
);
const contentViewFileContents = fs.readFileSync(filePath, 'utf8');
const moduleNameRegex = /moduleName: ".*"/g;

if (!contentViewFileContents.match(moduleNameRegex)) {
throw new Error('moduleName not found in ContentView.swift');
}

const isVanillaApp = appName === 'RNApp';

let updatedContentViewFileContents = contentViewFileContents.replace(
moduleNameRegex,
`moduleName: "${
isVanillaApp ? 'RNApp' : 'main' // default to main for Expo apps
}"`
);

logger.success(`Entrypoint name patched in ${filePath}`);

logger.info('Patching GreetingCard name in ContentView.swift');

// replace GreetingCard(name: "...") with GreetingCard(name: "${appName}")
const greetingCardNameRegex = /GreetingCard\(name: ".*"/g;
if (!updatedContentViewFileContents.match(greetingCardNameRegex)) {
throw new Error('GreetingCard name not found in ContentView.swift');
}

updatedContentViewFileContents = updatedContentViewFileContents.replace(
greetingCardNameRegex,
`GreetingCard(name: "iOS ${isVanillaApp ? 'Vanilla' : 'Expo'}"`
);

fs.writeFileSync(filePath, updatedContentViewFileContents);

outro(`Done!`);
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, open your project, go to **Package Dependencies**, click **+**, choose **Add Local...**, and select that `.brownfield/package/build` 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`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what this means? Does this step says how to add Package.swift to the Host App?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If not, perhaps we can add a note:

"Go to Project -> Package Dependencies -> Click on + -> Add Local -> Select the directory with Package.swift file, in this case it should be under `.brownfield/package/build"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified this in the docs. It now explicitly says to open the host app project in Xcode, go to Package Dependencies, click +, choose Add Local..., and select the .brownfield/package/build directory containing Package.swift.


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
Loading
Loading