diff --git a/README.md b/README.md index 3917fda..a71d923 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 6523936..52f299b 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 0000000..7a6b3bd --- /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 e959437..7399828 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 d0f27b8..e8c448f 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; },