diff --git a/.changeset/fuzzy-owls-explain.md b/.changeset/fuzzy-owls-explain.md new file mode 100644 index 00000000..04881f26 --- /dev/null +++ b/.changeset/fuzzy-owls-explain.md @@ -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. diff --git a/docs/docs/docs/api-reference/brownie/xcframework-packaging.mdx b/docs/docs/docs/api-reference/brownie/xcframework-packaging.mdx index 09da437c..4739d0df 100644 --- a/docs/docs/docs/api-reference/brownie/xcframework-packaging.mdx +++ b/docs/docs/docs/api-reference/brownie/xcframework-packaging.mdx @@ -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. @@ -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 diff --git a/docs/docs/docs/cli/brownfield.mdx b/docs/docs/docs/cli/brownfield.mdx index 1e029a30..0febfb5e 100644 --- a/docs/docs/docs/cli/brownfield.mdx +++ b/docs/docs/docs/cli/brownfield.mdx @@ -32,6 +32,7 @@ Available arguments: | --build-folder | Location for iOS build artifacts. Corresponds to Xcode's "-derivedDataPath". By default, the '\/.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 | @@ -45,6 +46,8 @@ The build directory will be placed in the `/.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: @@ -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. diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index 14d5a1f0..d241c01e 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -115,6 +115,14 @@ npx brownfield package:ios --scheme --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 --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) @@ -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 `` +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`**: diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts new file mode 100644 index 00000000..66b4a9aa --- /dev/null +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts @@ -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(); + return { + ...actual, + getBuildOptions: vi.fn(() => [ + { + name: '--configuration ', + description: 'test configuration option', + }, + { + name: '--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(); + 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); + }); +}); diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts index 7739d6dc..663279b2 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts @@ -2,17 +2,25 @@ import { Command, Option } from 'commander'; import * as rockTools from '@rock-js/tools'; import { describe, expect, test } from 'vitest'; -import { parseUsePrebuiltRnCoreArgument } from '../packageIos.js'; +import { + packageIosCommand, + parseUsePrebuiltRnCoreArgument, +} from '../packageIos.js'; /** Mirrors `--use-prebuilt-rn-core` on `packageIosCommand` (preset + parser). */ function parsePackageIosArgv(argv: string[]) { - const program = new Command('package:ios').addOption( - new Option('--use-prebuilt-rn-core [bool]', 'test') - .preset(true) - .argParser(parseUsePrebuiltRnCoreArgument) - ); + const program = new Command('package:ios') + .addOption( + new Option('--use-prebuilt-rn-core [bool]', 'test') + .preset(true) + .argParser(parseUsePrebuiltRnCoreArgument) + ) + .addOption(new Option('--add-spm-package', 'test')); program.parse(argv, { from: 'user' }); - return program.opts() as { usePrebuiltRnCore?: boolean }; + return program.opts() as { + usePrebuiltRnCore?: boolean; + addSpmPackage?: boolean; + }; } describe('parseUsePrebuiltRnCoreArgument', () => { @@ -64,3 +72,23 @@ describe('--use-prebuilt-rn-core (Commander)', () => { ).toBe(false); }); }); + +describe('--add-spm-package (Commander)', () => { + test('package:ios exposes the add-spm-package option', () => { + expect( + packageIosCommand.options.some( + (option) => option.long === '--add-spm-package' + ) + ).toBe(true); + }); + + test('omits property when flag is absent', () => { + expect(parsePackageIosArgv([]).addSpmPackage).toBeUndefined(); + }); + + test('bare flag sets addSpmPackage to true', () => { + expect(parsePackageIosArgv(['--add-spm-package']).addSpmPackage).toBe( + true + ); + }); +}); diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index 3f8eb75c..851e738c 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -30,6 +30,7 @@ import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNa import { copyDebugBundleToSimulatorSlice } from '../utils/copyDebugBundleToSimulatorSlice.js'; import { resolvePackagedFrameworkName } from '../utils/resolvePackagedFrameworkName.js'; import { stripFrameworkBinary } from '../utils/stripFrameworkBinary.js'; +import { createLocalSpmPackage } from '../utils/createLocalSpmPackage.js'; /** Help text for `--use-prebuilt-rn-core` (keep in sync with docs/docs/docs/getting-started/ios.mdx, "React Native Prebuilts" section). */ const USE_PREBUILT_RN_CORE_HELP = @@ -56,9 +57,23 @@ export function parseUsePrebuiltRnCoreArgument( ); } +function getPackagedFrameworkResolutionFailureMessage({ + resolution, + candidates, +}: { + resolution: string | null | undefined; + candidates?: string[]; +}) { + return resolution === 'ambiguous' + ? `found multiple bundled framework candidates (${candidates?.join(', ') ?? 'none'}); pass --scheme explicitly` + : 'could not resolve the packaged framework output automatically; pass --scheme explicitly'; +} + type PackageIosCliFlags = AppleBuildFlags & { /** Set when `--use-prebuilt-rn-core` is passed; omitted when the flag is absent (Rock applies RN version defaults). */ usePrebuiltRnCore?: boolean; + /** When set, generate a local Swift Package Manager manifest next to the packaged XCFramework outputs. */ + addSpmPackage?: boolean; }; export const packageIosCommand = curryOptions( @@ -79,6 +94,12 @@ export const packageIosCommand = curryOptions( .preset(true) .argParser(parseUsePrebuiltRnCoreArgument) ) + .addOption( + new Option( + '--add-spm-package', + 'Generate a local Swift Package Manager manifest next to the packaged XCFramework outputs' + ) + ) .action( actionRunner(async (options: PackageIosCliFlags) => { const { projectRoot, platformConfig, userConfig } = getProjectInfo('ios'); @@ -200,12 +221,21 @@ export const packageIosCommand = curryOptions( }); } } else if (configuration.includes('Debug')) { - const debugResolutionMessage = - resolution === 'ambiguous' - ? `Skipping Debug simulator JS bundle copy: found multiple bundled framework candidates (${candidates?.join(', ') ?? 'none'}); pass --scheme explicitly` - : 'Skipping Debug simulator JS bundle copy: could not resolve the packaged framework output automatically; pass --scheme explicitly'; + const debugResolutionFailureMessage = + getPackagedFrameworkResolutionFailureMessage({ + resolution, + candidates, + }); - logger.warn(debugResolutionMessage); + if (options.addSpmPackage) { + throw new RockError( + `Cannot generate local SPM package: ${debugResolutionFailureMessage}` + ); + } + + logger.warn( + `Skipping Debug simulator JS bundle copy: ${debugResolutionFailureMessage}` + ); } const reactBrownfieldXcframeworkPath = path.join( @@ -282,6 +312,23 @@ export const packageIosCommand = curryOptions( `BrownfieldNavigation.xcframework created at ${colorLink(relativeToCwd(brownfieldNavigationOutputPath))}` ); } + + if (options.addSpmPackage) { + const { packageManifestPath } = createLocalSpmPackage({ + packageDir, + frameworkName: frameworkName ?? undefined, + }); + + logger.success( + `Local SPM package manifest created at ${colorLink(relativeToCwd(packageManifestPath))}` + ); + logger.info( + `Add the local package folder in Xcode: ${colorLink(relativeToCwd(packageDir))}` + ); + logger.info( + "In Xcode, choose File > Add Package Dependencies..., click Add Local..., and select that folder." + ); + } }) ); diff --git a/packages/cli/src/brownfield/utils/__tests__/createLocalSpmPackage.test.ts b/packages/cli/src/brownfield/utils/__tests__/createLocalSpmPackage.test.ts new file mode 100644 index 00000000..ea7493e8 --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/createLocalSpmPackage.test.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createLocalSpmPackage } from '../createLocalSpmPackage.js'; + +function createXcframework(packageDir: string, name: string) { + fs.mkdirSync(path.join(packageDir, `${name}.xcframework`), { + recursive: true, + }); +} + +describe('createLocalSpmPackage', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-local-spm-package-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('writes Package.swift for the required XCFramework set', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + createXcframework(tempDir, 'ReactBrownfield'); + + const result = createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + }); + + expect(result.packageManifestPath).toBe(path.join(tempDir, 'Package.swift')); + + const manifest = fs.readFileSync(result.packageManifestPath, 'utf8'); + + expect(manifest).toContain('name: "BrownfieldLibPackage"'); + expect(manifest).toContain('library(name: "BrownfieldLib"'); + expect(manifest).toContain('binaryTarget(name: "BrownfieldLib"'); + expect(manifest).toContain('path: "./BrownfieldLib.xcframework"'); + expect(manifest).toContain('binaryTarget(name: "hermesvm"'); + expect(manifest).toContain('path: "./hermesvm.xcframework"'); + expect(manifest).toContain('binaryTarget(name: "ReactBrownfield"'); + expect(manifest).toContain('path: "./ReactBrownfield.xcframework"'); + + const readmePath = path.join(tempDir, 'README.md'); + expect(fs.existsSync(readmePath)).toBe(true); + + const readme = fs.readFileSync(readmePath, 'utf8'); + expect(readme).toContain('# BrownfieldLibPackage'); + expect(readme).toContain('local Swift Package Manager package'); + expect(readme).toContain('BrownfieldLib'); + expect(readme).toContain('Package.swift'); + }); + + it('includes Brownie and BrownfieldNavigation when those XCFrameworks exist', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + createXcframework(tempDir, 'ReactBrownfield'); + createXcframework(tempDir, 'Brownie'); + createXcframework(tempDir, 'BrownfieldNavigation'); + + const result = createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + }); + + const manifest = fs.readFileSync(result.packageManifestPath, 'utf8'); + + expect(manifest).toContain('binaryTarget(name: "Brownie"'); + expect(manifest).toContain('path: "./Brownie.xcframework"'); + expect(manifest).toContain('binaryTarget(name: "BrownfieldNavigation"'); + expect(manifest).toContain('path: "./BrownfieldNavigation.xcframework"'); + }); + + it('includes React and ReactNativeDependencies when those XCFrameworks exist', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + createXcframework(tempDir, 'ReactBrownfield'); + createXcframework(tempDir, 'React'); + createXcframework(tempDir, 'ReactNativeDependencies'); + + const result = createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + }); + + const manifest = fs.readFileSync(result.packageManifestPath, 'utf8'); + + expect(manifest).toContain('binaryTarget(name: "React"'); + expect(manifest).toContain('path: "./React.xcframework"'); + expect(manifest).toContain( + 'binaryTarget(name: "ReactNativeDependencies"' + ); + expect(manifest).toContain( + 'path: "./ReactNativeDependencies.xcframework"' + ); + }); + + it('uses hermes.xcframework when hermesvm.xcframework is not present', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermes'); + createXcframework(tempDir, 'ReactBrownfield'); + + const result = createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + }); + + const manifest = fs.readFileSync(result.packageManifestPath, 'utf8'); + + expect(manifest).toContain('binaryTarget(name: "hermes"'); + expect(manifest).toContain('path: "./hermes.xcframework"'); + }); + + it('throws a clear error when a required XCFramework is missing', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + + expect(() => + createLocalSpmPackage({ + packageDir: tempDir, + frameworkName: 'BrownfieldLib', + }) + ).toThrowError('Missing required XCFramework: ReactBrownfield.xcframework'); + }); + + it('detects the packaged app XCFramework when frameworkName is omitted', () => { + createXcframework(tempDir, 'BrownfieldLib'); + createXcframework(tempDir, 'hermesvm'); + createXcframework(tempDir, 'ReactBrownfield'); + createXcframework(tempDir, 'Brownie'); + + const result = createLocalSpmPackage({ + packageDir: tempDir, + }); + + const manifest = fs.readFileSync(result.packageManifestPath, 'utf8'); + + expect(manifest).toContain('name: "BrownfieldLibPackage"'); + expect(manifest).toContain('library(name: "BrownfieldLib"'); + expect(manifest).toContain('binaryTarget(name: "BrownfieldLib"'); + }); +}); diff --git a/packages/cli/src/brownfield/utils/createLocalSpmPackage.ts b/packages/cli/src/brownfield/utils/createLocalSpmPackage.ts new file mode 100644 index 00000000..44617447 --- /dev/null +++ b/packages/cli/src/brownfield/utils/createLocalSpmPackage.ts @@ -0,0 +1,190 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +type CreateLocalSpmPackageOptions = { + packageDir: string; + frameworkName?: string; +}; + +type CreateLocalSpmPackageResult = { + packageManifestPath: string; +}; + +const RESERVED_FRAMEWORK_NAMES = new Set([ + 'hermes', + 'hermesvm', + 'ReactBrownfield', + 'Brownie', + 'BrownfieldNavigation', + 'React', + 'ReactNativeDependencies', +]); + +function requireXcframework(packageDir: string, name: string) { + const xcframeworkPath = path.join(packageDir, `${name}.xcframework`); + + if (!fs.existsSync(xcframeworkPath)) { + throw new Error(`Missing required XCFramework: ${name}.xcframework`); + } + + return name; +} + +function optionalXcframework(packageDir: string, name: string) { + return fs.existsSync(path.join(packageDir, `${name}.xcframework`)) + ? name + : null; +} + +function requireHermesXcframework(packageDir: string) { + return ( + optionalXcframework(packageDir, 'hermesvm') ?? + requireXcframework(packageDir, 'hermes') + ); +} + +function resolveAppFrameworkName( + packageDir: string, + explicitFrameworkName?: string +) { + if (explicitFrameworkName) { + return requireXcframework(packageDir, explicitFrameworkName); + } + + const candidates = fs + .readdirSync(packageDir, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.endsWith('.xcframework') && + !RESERVED_FRAMEWORK_NAMES.has(path.basename(entry.name, '.xcframework')) + ) + .map((entry) => path.basename(entry.name, '.xcframework')) + .sort(); + + if (candidates.length === 1 && candidates[0]) { + return candidates[0]; + } + + if (candidates.length === 0) { + throw new Error( + 'Could not resolve the packaged app XCFramework automatically. Pass --scheme explicitly when packaging.' + ); + } + + throw new Error( + `Found multiple packaged app XCFramework candidates (${candidates.join(', ')}). Pass --scheme explicitly when packaging.` + ); +} + +function renderPackageSwift({ + packageName, + libraryName, + targetNames, +}: { + packageName: string; + libraryName: string; + targetNames: string[]; +}) { + const binaryTargets = targetNames + .map( + (targetName) => + ` .binaryTarget(name: "${targetName}", path: "./${targetName}.xcframework")` + ) + .join(',\n'); + + const targetDependencies = targetNames + .map((targetName) => `"${targetName}"`) + .join(', '); + + return `// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "${packageName}", + platforms: [ + .iOS(.v14), + ], + products: [ + .library(name: "${libraryName}", targets: [${targetDependencies}]), + ], + targets: [ +${binaryTargets} + ] +) +`; +} + +function renderReadme({ + packageName, + libraryName, + targetNames, +}: { + packageName: string; + libraryName: string; + targetNames: string[]; +}) { + const frameworks = targetNames.map((targetName) => `- \`${targetName}\``).join('\n'); + + return `# ${packageName} + +This is a generated local Swift Package Manager package for the packaged React Native brownfield artifacts. + +## Product + +- Library product: \`${libraryName}\` + +## Included Binary Targets + +${frameworks} + +## How To Use + +1. In Xcode, choose **File > Add Package Dependencies...** +2. Click **Add Local...** +3. Select this folder, the one containing both \`Package.swift\` and the \`*.xcframework\` artifacts +4. Add the \`${libraryName}\` library product to your app target + +This folder is generated by \`brownfield package:ios --add-spm-package\`. Re-run that command whenever the packaged XCFrameworks change so this package stays in sync. +`; +} + +export function createLocalSpmPackage({ + packageDir, + frameworkName, +}: CreateLocalSpmPackageOptions): CreateLocalSpmPackageResult { + const resolvedFrameworkName = resolveAppFrameworkName( + packageDir, + frameworkName + ); + const targetNames = [ + resolvedFrameworkName, + requireHermesXcframework(packageDir), + requireXcframework(packageDir, 'ReactBrownfield'), + optionalXcframework(packageDir, 'Brownie'), + optionalXcframework(packageDir, 'BrownfieldNavigation'), + optionalXcframework(packageDir, 'React'), + optionalXcframework(packageDir, 'ReactNativeDependencies'), + ].filter((targetName): targetName is string => targetName !== null); + + const packageManifestPath = path.join(packageDir, 'Package.swift'); + const readmePath = path.join(packageDir, 'README.md'); + const manifest = renderPackageSwift({ + packageName: `${resolvedFrameworkName}Package`, + libraryName: resolvedFrameworkName, + targetNames, + }); + const readme = renderReadme({ + packageName: `${resolvedFrameworkName}Package`, + libraryName: resolvedFrameworkName, + targetNames, + }); + + fs.writeFileSync(packageManifestPath, manifest, 'utf8'); + fs.writeFileSync(readmePath, readme, 'utf8'); + + return { + packageManifestPath, + }; +}