From af231a437551a6c0b2890de1296182a39e04146a Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Mon, 28 Sep 2026 22:24:28 -0400 Subject: [PATCH] feat(plugin): add iosDependencyManager 'spm' to the Expo config plugin With `iosDependencyManager: 'spm'`, the plugin turns on the pod's Swift Package Manager mode and calls mparticle_spm_post_install in post_install. It requires the helper through node resolution and skips the dynamic pre_install hook and the kit pods. iosKits names map to their Swift packages (mParticle-Rokt today); other kits go in iosSpmKits, and iosSdkVersion pins the core. Values are validated before they are written into the Podfile. The default, 'cocoapods', produces exactly the Podfile it did before. The Podfile edits move into an exported applyMParticlePodfileMods so jest can cover both modes. The helper now gives a kit without a version the core's version, since mParticle kits release in lockstep with the core. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 21 ++- ios/mparticle_spm.rb | 4 +- js/__tests__/plugin-ios-spm.test.ts | 163 +++++++++++++++++++ plugin/src/withMParticle.ts | 33 ++++ plugin/src/withMParticleIOS.ts | 241 ++++++++++++++++++++++------ 5 files changed, 409 insertions(+), 53 deletions(-) create mode 100644 js/__tests__/plugin-ios-spm.test.ts diff --git a/README.md b/README.md index 3917fdae..a71d9230 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ npx expo run:android | `dataPlanId` | string | No | Data plan ID for validation | | `dataPlanVersion` | number | No | Data plan version (ignored unless `dataPlanId` is also set) | | `iosKits` | string[] | No | iOS kit pod names (e.g., `['mParticle-Rokt']`) | +| `iosDependencyManager` | string | No | `'cocoapods'` (default) or `'spm'`: take the iOS SDK and kits from Swift Package Manager (see below) | +| `iosSdkVersion` | string | No | With `'spm'`, the exact mParticle core SDK version, also used for kits without a version | +| `iosSpmKits` | object[] | No | With `'spm'`, extra kits as `{ url, product, version? }` Swift packages | | `customBaseUrl` | string | No | Custom base URL for global CNAME setup on iOS and Android; must be an absolute `https://` URL or prebuild fails | | `pinningDisabled` | boolean | No | Disable SSL pinning (`MPNetworkOptions` on iOS; `setPinningDisabledInDevelopment` on Android) | | `androidKits` | string[] | No | Android kit artifact names (e.g., `['android-rokt-kit']`) | @@ -128,6 +131,21 @@ For global CNAME setup, add the optional shared `customBaseUrl` setting: } ``` +### Swift Package Manager (Expo) + +Set `iosDependencyManager` to `'spm'` to take the iOS mParticle SDK and kits from Swift Package Manager instead of CocoaPods ([details](#swift-package-manager-opt-in)). `iosKits` names are mapped to their Swift packages; today that is `mParticle-Rokt`. List any other kit in `iosSpmKits`, or prebuild fails with an error naming it. + +```json +{ + "iosDependencyManager": "spm", + "iosKits": ["mParticle-Rokt"] +} +``` + +Expo regenerates `ios/` on `expo prebuild --clean`, so there is no committed `Package.resolved`. Set `iosSdkVersion` to pin the core SDK (and kits without a version) exactly. With `'spm'`, the core SDK package is always linked, so `import mParticle_Apple_SDK` resolves without declaring the umbrella pod. + +When you switch `iosDependencyManager` in either direction, run `npx expo prebuild --clean`, so the previous mode's Podfile lines and Swift packages are not left behind. + ### What the Plugin Does **iOS:** @@ -136,6 +154,7 @@ For global CNAME setup, add the optional shared `customBaseUrl` setting: - Sets `MPNetworkOptions` (`customBaseURL` and/or `pinningDisabled`) before startup when those plugin options are configured - Configures `pre_install` hook in Podfile for dynamic framework linking, covering the kit's transitive pods (skipped if the Podfile already mentions `mParticle-Apple-SDK`) - Adds specified kit pod dependencies — `mParticle-Rokt` is pinned to `>= 9.3.1, < 10.0`, other kits are added unpinned +- With `iosDependencyManager: 'spm'`, instead of the two items above: turns on this package's Swift Package Manager mode in the Podfile and calls `mparticle_spm_post_install`, which links the core SDK and kits into the app target as Swift packages pinned to exact versions **Android:** @@ -379,7 +398,7 @@ Every mParticle and Rokt SDK must then come from Swift Package Manager, and none ]) ``` - `core_version:` pins the core SDK; it defaults to the version this release was tested with. + `core_version:` pins the core SDK; it defaults to the version this release was tested with. A kit without a `version:` gets the core's version, since mParticle kits are released with the core. 4. Run `pod install`, then build as usual. diff --git a/ios/mparticle_spm.rb b/ios/mparticle_spm.rb index 6523936c..52f299bd 100644 --- a/ios/mparticle_spm.rb +++ b/ios/mparticle_spm.rb @@ -80,7 +80,9 @@ def self.add_package(project, target, url:, product:, version:) def mparticle_spm_post_install(installer, core_version: MParticleSPM::DEFAULT_CORE_VERSION, kits: []) MParticleSPM.guard!(installer) - packages = [{ url: MParticleSPM::CORE_URL, product: MParticleSPM::CORE_PRODUCT, version: core_version }] + kits + # mParticle kits are released in lockstep with the core, so a kit without a version gets the core's. + packages = [{ url: MParticleSPM::CORE_URL, product: MParticleSPM::CORE_PRODUCT, version: core_version }] + + kits.map { |kit| { version: core_version }.merge(kit) } touched = {} MParticleSPM.application_targets(installer).each do |project, target| packages.each do |pkg| diff --git a/js/__tests__/plugin-ios-spm.test.ts b/js/__tests__/plugin-ios-spm.test.ts new file mode 100644 index 00000000..7a6b3bd5 --- /dev/null +++ b/js/__tests__/plugin-ios-spm.test.ts @@ -0,0 +1,163 @@ +import { applyMParticlePodfileMods } from '../../plugin/src/withMParticleIOS'; +import type { MParticlePluginProps } from '../../plugin/src/withMParticle'; + +/** + * The Expo config plugin's Podfile changes. `iosDependencyManager: 'cocoapods'` (the default) + * must keep producing exactly what earlier releases produced; `'spm'` turns on the pod's Swift + * Package Manager mode and calls ios/mparticle_spm.rb instead of adding pods. + */ + +// The part of the Expo prebuild template the plugin touches. +const EXPO_PODFILE = `require File.join(File.dirname(\`node --print "require.resolve('expo/package.json')"\`), "scripts/autolinking") +platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' + +prepare_react_native_project! + +target 'MyApp' do + use_expo_modules! + config = use_native_modules!(config_command) + + use_react_native!( + :path => config[:reactNativePath], + :app_path => "#{Pod::Config.instance.installation_root}/..", + ) + + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + ) + end +end +`; + +const baseProps: MParticlePluginProps = { + iosApiKey: 'key', + iosApiSecret: 'secret', + androidApiKey: 'key', + androidApiSecret: 'secret', + iosKits: ['mParticle-Rokt'], +}; + +const spmProps: MParticlePluginProps = { + ...baseProps, + iosDependencyManager: 'spm', +}; + +const ROKT_CALL = + " mparticle_spm_post_install(installer, kits: [{ url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt' }])"; + +describe('applyMParticlePodfileMods', () => { + it('keeps the CocoaPods output unchanged', () => { + expect(applyMParticlePodfileMods(EXPO_PODFILE, baseProps)).toBe( + EXPO_PODFILE.replace( + "'15.1'\n", + `'15.1' + +# mParticle dynamic framework linking (added by react-native-mparticle expo plugin) +pre_install do |installer| + installer.pod_targets.each do |pod| + if pod.name == 'mParticle-Apple-SDK' || pod.name == 'mParticle-Apple-SDK-ObjC' || pod.name == 'mParticle-Apple-SDK-Swift' || pod.name == 'mParticle-Rokt' || pod.name == 'Rokt-Widget' || pod.name == 'RoktContracts' || pod.name == 'RoktUXHelper' || pod.name == 'DcuiSchema' + def pod.build_type; + Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework) + end + end + end +end +` + ).replace( + ' )\n\n post_install', + ` ) + + # mParticle kits (added by react-native-mparticle expo plugin) + pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0' + + post_install` + ) + ); + }); + + it('sets the flag before the target, requires the helper and calls it in post_install', () => { + const podfile = applyMParticlePodfileMods(EXPO_PODFILE, spmProps); + + expect(podfile).toBe( + EXPO_PODFILE.replace( + "target 'MyApp' do", + `# mParticle Swift Package Manager mode (added by react-native-mparticle expo plugin) +$RNMParticleUseSPM = true +require File.join(File.dirname(\`node --print "require.resolve('react-native-mparticle/package.json')"\`.strip), 'ios', 'mparticle_spm') + +target 'MyApp' do` + ).replace( + 'post_install do |installer|\n', + `post_install do |installer|\n${ROKT_CALL}\n` + ) + ); + expect(podfile).not.toContain('pre_install'); + expect(podfile).not.toContain("pod 'mParticle-Rokt'"); + }); + + it('passes the core version and extra kits through', () => { + const podfile = applyMParticlePodfileMods(EXPO_PODFILE, { + ...spmProps, + iosSdkVersion: '9.6.1', + iosSpmKits: [ + { + url: 'https://github.com/example/kit', + product: 'Example-Kit', + version: '1.2.3', + }, + ], + }); + + expect(podfile).toContain( + "mparticle_spm_post_install(installer, core_version: '9.6.1', kits: [{ url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt' }, { url: 'https://github.com/example/kit', product: 'Example-Kit', version: '1.2.3' }])" + ); + }); + + it('is idempotent, and a rerun with new settings replaces the call', () => { + const once = applyMParticlePodfileMods(EXPO_PODFILE, spmProps); + expect(applyMParticlePodfileMods(once, spmProps)).toBe(once); + + const pinned = applyMParticlePodfileMods(once, { + ...spmProps, + iosSdkVersion: '9.7.0', + }); + expect(pinned.match(/\$RNMParticleUseSPM = true/g)).toHaveLength(1); + expect(pinned.match(/mparticle_spm_post_install\(/g)).toHaveLength(1); + expect(pinned).toContain("core_version: '9.7.0'"); + }); + + it('rejects an iosKits name with no known Swift package', () => { + expect(() => + applyMParticlePodfileMods(EXPO_PODFILE, { + ...spmProps, + iosKits: ['mParticle-Amplitude'], + }) + ).toThrow(/"mParticle-Amplitude" has no known Swift package.*iosSpmKits/); + }); + + it('rejects values that would break out of the generated Ruby string', () => { + expect(() => + applyMParticlePodfileMods(EXPO_PODFILE, { + ...spmProps, + iosSdkVersion: "9.6.1'); system('echo", + }) + ).toThrow(/invalid iosSdkVersion/); + expect(() => + applyMParticlePodfileMods(EXPO_PODFILE, { + ...spmProps, + iosSpmKits: [{ url: 'http://example.com/kit', product: 'Kit' }], + }) + ).toThrow(/invalid Swift package URL/); + }); + + it('rejects an unknown dependency manager', () => { + expect(() => + applyMParticlePodfileMods(EXPO_PODFILE, { + ...baseProps, + iosDependencyManager: 'carthage' as 'spm', + }) + ).toThrow(/must be "cocoapods" or "spm"/); + }); +}); diff --git a/plugin/src/withMParticle.ts b/plugin/src/withMParticle.ts index e9594372..7399828d 100644 --- a/plugin/src/withMParticle.ts +++ b/plugin/src/withMParticle.ts @@ -4,6 +4,18 @@ import { withMParticleAndroid } from './withMParticleAndroid'; const pkg = require('../../package.json'); +/** + * A kit taken from Swift Package Manager (`iosDependencyManager: 'spm'`) + */ +export interface IosSpmKit { + /** Package repository URL, e.g. `https://github.com/mparticle-integrations/mp-apple-integration-rokt` */ + url: string; + /** Package product to link, e.g. `mParticle-Rokt` */ + product: string; + /** Exact version; defaults to the core SDK version */ + version?: string; +} + /** * mParticle plugin configuration options */ @@ -56,6 +68,27 @@ export interface MParticlePluginProps { */ iosKits?: string[]; + /** + * Where the iOS mParticle SDK and kits come from. + * - `'cocoapods'`: pods, as before. + * - `'spm'`: Swift packages linked into the app target (README › Swift Package Manager). + * `iosKits` names are mapped to their Swift packages; list any other kit in `iosSpmKits`. + * @default 'cocoapods' + */ + iosDependencyManager?: 'cocoapods' | 'spm'; + + /** + * In `'spm'` mode, the exact mParticle core SDK version, also used for kits without a version. + * @default the version this release of react-native-mparticle was tested with + */ + iosSdkVersion?: string; + + /** + * In `'spm'` mode, extra kits as Swift packages. + * @example [{ url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt' }] + */ + iosSpmKits?: IosSpmKit[]; + /** * Custom base URL for global CNAME setup. * This is applied before mParticle starts on iOS and Android. diff --git a/plugin/src/withMParticleIOS.ts b/plugin/src/withMParticleIOS.ts index d0f27b89..e8c448f8 100644 --- a/plugin/src/withMParticleIOS.ts +++ b/plugin/src/withMParticleIOS.ts @@ -4,7 +4,7 @@ import { withDangerousMod, } from '@expo/config-plugins'; import { mergeContents } from '@expo/config-plugins/build/utils/generateCode'; -import { MParticlePluginProps } from './withMParticle'; +import { IosSpmKit, MParticlePluginProps } from './withMParticle'; import { getCustomBaseUrl } from './customBaseUrl'; import * as fs from 'fs'; import * as path from 'path'; @@ -423,35 +423,148 @@ function getKitPodDeclaration(kit: string): string { } /** - * Add kit pods and pre_install hook to Podfile + * Swift packages for the `iosKits` names that have one, for `iosDependencyManager: 'spm'`. */ -const withMParticlePodfile: ConfigPlugin = ( - config, - props -) => { - return withDangerousMod(config, [ - 'ios', - async config => { - const podfilePath = path.join( - config.modRequest.platformProjectRoot, - 'Podfile' +const KIT_SWIFT_PACKAGES: Record = { + 'mParticle-Rokt': { + url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', + product: 'mParticle-Rokt', + }, +}; + +const SPM_MARKER = + '# mParticle Swift Package Manager mode (added by react-native-mparticle expo plugin)'; + +// These values are written into the Podfile as Ruby string literals, so allow no quotes or escapes. +const SPM_URL = /^https:\/\/[A-Za-z0-9._~/-]+$/; +const SPM_NAME = /^[A-Za-z0-9._+-]+$/; + +function rubyString(value: string, pattern: RegExp, what: string): string { + if (!pattern.test(value)) { + throw new Error( + `react-native-mparticle plugin: invalid ${what} ${JSON.stringify(value)}` + ); + } + return `'${value}'`; +} + +function getSpmKits(props: MParticlePluginProps): IosSpmKit[] { + const mapped = (props.iosKits ?? []).map(kit => { + const pkg = KIT_SWIFT_PACKAGES[kit]; + if (!pkg) { + throw new Error( + `react-native-mparticle plugin: iosKits entry "${kit}" has no known Swift package. ` + + 'With iosDependencyManager "spm", list it in iosSpmKits as { url, product, version } instead.' ); + } + return pkg; + }); + return [...mapped, ...(props.iosSpmKits ?? [])]; +} - if (!fs.existsSync(podfilePath)) { - return config; +function getSpmPostInstallCall(props: MParticlePluginProps): string { + const kits = getSpmKits(props) + .map(kit => { + const fields = [ + `url: ${rubyString(kit.url, SPM_URL, 'Swift package URL')}`, + `product: ${rubyString( + kit.product, + SPM_NAME, + 'Swift package product' + )}`, + ]; + if (kit.version) { + fields.push( + `version: ${rubyString( + kit.version, + SPM_NAME, + 'Swift package version' + )}` + ); } + return `{ ${fields.join(', ')} }`; + }) + .join(', '); + const coreVersion = props.iosSdkVersion + ? `, core_version: ${rubyString( + props.iosSdkVersion, + SPM_NAME, + 'iosSdkVersion' + )}` + : ''; + return `mparticle_spm_post_install(installer${coreVersion}, kits: [${kits}])`; +} + +/** + * `iosDependencyManager: 'spm'`: turn on the pod's Swift Package Manager mode and call the + * helper that links the SDK packages into the app target. No mParticle pods or pre_install hook. + */ +function applySpmPodfileMods( + podfileContent: string, + props: MParticlePluginProps +): string { + const call = getSpmPostInstallCall(props); + let contents = podfileContent; + + // The flag must be set before the first target block, which evaluates the podspec. + if (!contents.includes(SPM_MARKER)) { + const firstTarget = /^target /m; + if (!firstTarget.test(contents)) { + throw new Error( + 'react-native-mparticle plugin: no target block found in ios/Podfile' + ); + } + const header = [ + SPM_MARKER, + '$RNMParticleUseSPM = true', + `require File.join(File.dirname(\`node --print "require.resolve('react-native-mparticle/package.json')"\`.strip), 'ios', 'mparticle_spm')`, + '', + ].join('\n'); + contents = contents.replace(firstTarget, match => `${header}\n${match}`); + } + + // Replace an existing call, so a non-clean prebuild picks up changed versions or kits. + const existingCall = /^ *mparticle_spm_post_install\(.*\)$/m; + if (existingCall.test(contents)) { + return contents.replace(existingCall, () => ` ${call}`); + } + const postInstall = /post_install do \|installer\|\n/; + if (!postInstall.test(contents)) { + throw new Error( + 'react-native-mparticle plugin: no post_install block found in ios/Podfile' + ); + } + return contents.replace(postInstall, match => `${match} ${call}\n`); +} - let podfileContent = fs.readFileSync(podfilePath, 'utf-8'); +/** + * Returns the Podfile with this plugin's changes applied. Exported for tests. + */ +export function applyMParticlePodfileMods( + podfileContent: string, + props: MParticlePluginProps +): string { + const manager = props.iosDependencyManager ?? 'cocoapods'; + if (manager === 'spm') { + return applySpmPodfileMods(podfileContent, props); + } + if (manager !== 'cocoapods') { + throw new Error( + `react-native-mparticle plugin: iosDependencyManager must be "cocoapods" or "spm", got ${JSON.stringify( + manager + )}` + ); + } - // Add pre_install hook for dynamic framework linking if not already present - if (!podfileContent.includes('mParticle-Apple-SDK')) { - // Get all pods that need dynamic linking (including transitive dependencies) - const dynamicPods = getDynamicFrameworkPods(props.iosKits); - const podConditions = dynamicPods - .map(pod => `pod.name == '${pod}'`) - .join(' || '); + // Add pre_install hook for dynamic framework linking if not already present + if (!podfileContent.includes('mParticle-Apple-SDK')) { + // Get all pods that need dynamic linking (including transitive dependencies) + const dynamicPods = getDynamicFrameworkPods(props.iosKits); + const podConditions = dynamicPods + .map(pod => `pod.name == '${pod}'`) + .join(' || '); - const preInstallHook = ` + const preInstallHook = ` # mParticle dynamic framework linking (added by react-native-mparticle expo plugin) pre_install do |installer| installer.pod_targets.each do |pod| @@ -464,39 +577,65 @@ pre_install do |installer| end `; - // Add pre_install hook after platform declaration - const platformRegex = /platform :ios.*\n/; - if (platformRegex.test(podfileContent)) { - podfileContent = podfileContent.replace( - platformRegex, - `$&${preInstallHook}` - ); - } - } + // Add pre_install hook after platform declaration + const platformRegex = /platform :ios.*\n/; + if (platformRegex.test(podfileContent)) { + podfileContent = podfileContent.replace( + platformRegex, + `$&${preInstallHook}` + ); + } + } + + // Add kit pods if specified. Kits are matched individually so a Podfile that + // already declares one kit does not get it re-injected alongside a missing one. + if (props.iosKits && props.iosKits.length > 0) { + const missingKits = props.iosKits.filter( + kit => !podfileContent.includes(`pod '${kit}'`) + ); + + if (missingKits.length > 0) { + const kitPods = missingKits.map(getKitPodDeclaration).join('\n'); - // Add kit pods if specified. Kits are matched individually so a Podfile that - // already declares one kit does not get it re-injected alongside a missing one. - if (props.iosKits && props.iosKits.length > 0) { - const missingKits = props.iosKits.filter( - kit => !podfileContent.includes(`pod '${kit}'`) + // Add kit pods inside the main target block + // Look for use_react_native! and add after it + const useReactNativeRegex = /(use_react_native!\([^)]*\))/s; + if (useReactNativeRegex.test(podfileContent)) { + podfileContent = podfileContent.replace( + useReactNativeRegex, + `$1\n\n # mParticle kits (added by react-native-mparticle expo plugin)\n${kitPods}` ); + } + } + } - if (missingKits.length > 0) { - const kitPods = missingKits.map(getKitPodDeclaration).join('\n'); - - // Add kit pods inside the main target block - // Look for use_react_native! and add after it - const useReactNativeRegex = /(use_react_native!\([^)]*\))/s; - if (useReactNativeRegex.test(podfileContent)) { - podfileContent = podfileContent.replace( - useReactNativeRegex, - `$1\n\n # mParticle kits (added by react-native-mparticle expo plugin)\n${kitPods}` - ); - } - } + return podfileContent; +} + +/** + * Add kit pods and pre_install hook to Podfile, or the Swift Package Manager mode lines + */ +const withMParticlePodfile: ConfigPlugin = ( + config, + props +) => { + return withDangerousMod(config, [ + 'ios', + async config => { + const podfilePath = path.join( + config.modRequest.platformProjectRoot, + 'Podfile' + ); + + if (!fs.existsSync(podfilePath)) { + return config; } - fs.writeFileSync(podfilePath, podfileContent); + const podfileContent = fs.readFileSync(podfilePath, 'utf-8'); + fs.writeFileSync( + podfilePath, + applyMParticlePodfileMods(podfileContent, props) + ); return config; },