From afaa4f8a768e47f39fc4189c69e654ae5671a78c Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Wed, 30 Sep 2026 15:55:27 -0400 Subject: [PATCH] feat(ios)!: take the mParticle SDKs from Swift Package Manager by default The mParticle Apple SDK will publish no CocoaPods releases after CocoaPods trunk becomes read-only on 2 December 2026. The mParticle core SDK and its kits now come from Swift Package Manager by default, linked into the app target. React Native and this package still install with CocoaPods. The podspec loads ios/mparticle_spm.rb, which hooks pod install itself, so no Podfile call is needed. Kits are listed by CocoaPods name in $RNMParticleSPMKits and mapped through ios/mparticle_spm_kits.json, which covers every kit of the mParticle Apple SDK. pod install stops with both fixes named when a kit pod or tvOS target is left in. $RNMParticleDisableSPM = true keeps the CocoaPods path, which is deprecated. The Expo config plugin now defaults to 'spm' and reads the same kit table; 'cocoapods' is the opt-out. The sample and CI flip to match. BREAKING CHANGE: an app that declares mParticle kit pods must move them to $RNMParticleSPMKits, or set $RNMParticleDisableSPM = true. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/pull-request.yml | 4 +- MIGRATING.md | 23 +-- README.md | 214 ++++++++++++++-------------- ios/mparticle_spm.rb | 184 +++++++++++++++++++----- ios/mparticle_spm_kits.json | 134 +++++++++++++++++ js/__tests__/plugin-ios-spm.test.ts | 185 +++++++++++++++--------- plugin/src/withMParticle.ts | 17 +-- plugin/src/withMParticleIOS.ts | 166 ++++++++++----------- react-native-mparticle.podspec | 11 +- sample/ios/Podfile | 21 ++- 10 files changed, 636 insertions(+), 323 deletions(-) create mode 100644 ios/mparticle_spm_kits.json diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b54d41b..a9bcad5 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -126,8 +126,8 @@ jobs: - ios_deps: spm check_name: iOS Sample App (SPM) env: - # sample/ios/Podfile takes the mParticle SDKs from Swift Package Manager when this is 1. - MP_USE_SPM: ${{ matrix.ios_deps == 'spm' && '1' || '0' }} + # sample/ios/Podfile takes the mParticle SDKs from Swift Package Manager unless this is 1. + MP_USE_COCOAPODS: ${{ matrix.ios_deps == 'cocoapods' && '1' || '0' }} steps: - name: Checkout uses: actions/checkout@v7 diff --git a/MIGRATING.md b/MIGRATING.md index fe36472..f8dae5c 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -2,21 +2,24 @@ This document provides migration guidance for changes in `react-native-mparticle`. -## Moving an iOS app to Swift Package Manager mode +## iOS: the mParticle SDKs now come from Swift Package Manager -Optional, iOS only. The default CocoaPods setup keeps working unchanged. See [README › Swift Package Manager (opt-in)](./README.md#swift-package-manager-opt-in) for what the mode does. +**Breaking, iOS only.** React Native and this package still install with CocoaPods, but the mParticle core SDK and its kits now come from Swift Package Manager by default, linked into the app target. CocoaPods trunk becomes read-only on 2 December 2026. An app with no kits needs no change. An app that declares kit pods fails `pod install` until it moves them, or opts out. See [README › Swift Package Manager](./README.md#swift-package-manager). -1. In `ios/Podfile`, add `$RNMParticleUseSPM = true` and `require_relative '../node_modules/react-native-mparticle/ios/mparticle_spm'` above the first `target` block. -2. Remove every mParticle and Rokt pod you declared, such as `pod 'mParticle-Rokt'`, `pod 'mParticle-Apple-SDK'` or `pod 'Rokt-Widget'`. -3. Remove the mParticle, Rokt, `RoktContracts`, `RoktUXHelper` and `DcuiSchema` names from any `pre_install` hook that makes them dynamic frameworks. If the hook lists nothing else, delete it. -4. If you added mParticle or Rokt Swift packages to the app target by hand, remove them in Xcode (target › General › Frameworks, Libraries, and Embedded Content, and the project's Package Dependencies). The helper adds them back, pinned. -5. In `post_install`, after `react_native_post_install(...)`, call `mparticle_spm_post_install(installer, kits: [...])` with one entry per kit. -6. Run `pod install`. It prints each package it adds, and fails with a list of pods to remove if any would add a second copy of the SDK. -7. Build, run a Debug build once to confirm no red box, and commit the `.xcodeproj` change and `Package.resolved`. +To move a bare React Native app: + +1. In `ios/Podfile`, remove every mParticle and Rokt pod you declared, such as `pod 'mParticle-Rokt'`, `pod 'mParticle-Apple-SDK'` or `pod 'Rokt-Widget'`. +2. Remove the mParticle, Rokt, `RoktContracts`, `RoktUXHelper` and `DcuiSchema` names from any `pre_install` hook that makes them dynamic frameworks. If the hook lists nothing else, delete it. +3. If you added mParticle or Rokt Swift packages to the app target by hand, remove them in Xcode (target › General › Frameworks, Libraries, and Embedded Content, and the project's Package Dependencies). `pod install` adds them back, pinned. +4. Above the first `target` block, list your kits by CocoaPods name: `$RNMParticleSPMKits = ['mParticle-Rokt']`. +5. Run `pod install`. It prints each package it adds, and fails with a list of pods to remove if any would add a second copy of the SDK. +6. Build, run a Debug build once to confirm no red box, and commit the `.xcodeproj` change and `Package.resolved`. A Swift `AppDelegate` needs no change: `import mParticle_Apple_SDK` resolves from the Swift package. -To go back, undo these steps: remove `$RNMParticleUseSPM` and the helper call, restore your kit pods and the `pre_install` hook, remove the packages from the app target, and run `pod install`. +Expo apps need no change beyond `npx expo prebuild --clean`: the plugin maps `iosKits` to their Swift packages. A kit the plugin does not know goes in `iosSpmKits`. + +To stay on CocoaPods for now, add `$RNMParticleDisableSPM = true` at the top of `ios/Podfile`, before any `target` block, and keep your pods (Expo: `"iosDependencyManager": "cocoapods"`). This is deprecated, and a Podfile with a tvOS target that uses this package must do it. ## Migrating embedded placements to placeholder names diff --git a/README.md b/README.md index 653b603..ac2770e 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ npx expo install react-native-mparticle ["expo-build-properties", { "ios": { "deploymentTarget": "15.6" } }] ``` -**If you set no `iosKits`, declare the umbrella pod.** On a Swift AppDelegate (Expo SDK 53+) the plugin writes `import mParticle_Apple_SDK`, but nothing installs that pod on its own: this library depends on `mParticle-Apple-SDK-ObjC`, and the umbrella arrives transitively only with a kit (`mParticle-Rokt` depends on `mParticle-Apple-SDK`). Without a kit, add `pod 'mParticle-Apple-SDK', '>= 9.2.2', '< 10.0'` to the generated `ios/Podfile`, and re-apply it after any `expo prebuild --clean`, which rewrites that file. Objective-C templates get `#import "mParticle.h"` instead, which resolves without the umbrella. +**With `iosDependencyManager: 'cocoapods'` and no `iosKits`, declare the umbrella pod.** Swift Package Manager, the default, always links the core SDK, so this applies only to CocoaPods. On a Swift AppDelegate (Expo SDK 53+) the plugin writes `import mParticle_Apple_SDK`, but nothing installs that pod on its own: this library depends on `mParticle-Apple-SDK-ObjC`, and the umbrella arrives transitively only with a kit (`mParticle-Rokt` depends on `mParticle-Apple-SDK`). Without a kit, add `pod 'mParticle-Apple-SDK', '>= 9.2.2', '< 10.0'` to the generated `ios/Podfile`, and re-apply it after any `expo prebuild --clean`, which rewrites that file. Objective-C templates get `#import "mParticle.h"` instead, which resolves without the umbrella. 3. Run prebuild: @@ -80,24 +80,24 @@ npx expo run:android ### Plugin Configuration Options -| Option | Type | Required | Description | -| ------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------- | -| `iosApiKey` | string | Yes | iOS API key from mParticle dashboard | -| `iosApiSecret` | string | Yes | iOS API secret from mParticle dashboard | -| `androidApiKey` | string | Yes | Android API key from mParticle dashboard | -| `androidApiSecret` | string | Yes | Android API secret from mParticle dashboard | -| `logLevel` | string | No | Log level: `'none'`, `'error'`, `'warning'`, `'debug'`, `'verbose'` | -| `environment` | string | No | Environment: `'development'`, `'production'`, `'autoDetect'` | -| `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']`) | -| `useEmptyIdentifyRequest` | boolean | No | Use empty user identify request at init (default: `true`) | +| Option | Type | Required | Description | +| ------------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `iosApiKey` | string | Yes | iOS API key from mParticle dashboard | +| `iosApiSecret` | string | Yes | iOS API secret from mParticle dashboard | +| `androidApiKey` | string | Yes | Android API key from mParticle dashboard | +| `androidApiSecret` | string | Yes | Android API secret from mParticle dashboard | +| `logLevel` | string | No | Log level: `'none'`, `'error'`, `'warning'`, `'debug'`, `'verbose'` | +| `environment` | string | No | Environment: `'development'`, `'production'`, `'autoDetect'` | +| `dataPlanId` | string | No | Data plan ID for validation | +| `dataPlanVersion` | number | No | Data plan version (ignored unless `dataPlanId` is also set) | +| `iosKits` | string[] | No | iOS kits by CocoaPods name (e.g., `['mParticle-Rokt']`); see [kit names](#swift-package-manager) | +| `iosDependencyManager` | string | No | `'spm'` (default) takes the iOS SDK and kits from Swift Package Manager; `'cocoapods'` is the deprecated opt-out | +| `iosSdkVersion` | string | No | With `'spm'`, the exact mParticle core SDK version, also used for kits without a version | +| `iosSpmKits` | object[] | No | With `'spm'`, kits not in the kit list, 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']`) | +| `useEmptyIdentifyRequest` | boolean | No | Use empty user identify request at init (default: `true`) | ### Example with Kits @@ -114,7 +114,7 @@ npx expo run:android "androidApiSecret": "YOUR_ANDROID_API_SECRET", "environment": "development", "logLevel": "verbose", - "iosKits": ["mParticle-Rokt", "mParticle-Amplitude"], + "iosKits": ["mParticle-Rokt", "mParticle-Braze-14"], "androidKits": ["android-rokt-kit", "android-amplitude-kit"] } ] @@ -133,18 +133,17 @@ 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. +The plugin takes the iOS mParticle SDK and kits from Swift Package Manager by default ([details](#swift-package-manager)). `iosKits` names are the CocoaPods names of the kits; list any kit that is not in the kit list 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. +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. 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. +To stay on CocoaPods, which is deprecated, set `"iosDependencyManager": "cocoapods"`. 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 @@ -152,9 +151,8 @@ When you switch `iosDependencyManager` in either direction, run `npx expo prebui - Adds mParticle SDK initialization to `AppDelegate` (supports both Swift and Objective-C) - 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 +- Writes `$RNMParticleSPMKits` (and `$RNMParticleSPMCoreVersion` when `iosSdkVersion` is set) at the top of the Podfile. On `pod install`, this package links the core SDK and those kits into the app target as Swift packages pinned to exact versions +- With `iosDependencyManager: 'cocoapods'`, instead: sets `$RNMParticleDisableSPM = true`, configures a `pre_install` hook in the Podfile for dynamic framework linking, covering the kit's transitive pods (skipped if the Podfile already mentions `mParticle-Apple-SDK`), and adds the kit pods — `mParticle-Rokt` is pinned to `>= 9.3.1, < 10.0`, other kits are added unpinned **Android:** @@ -181,9 +179,7 @@ The plugin generates code for the language Expo reports — `swift`, `objc` or ` [1]: https://app.mparticle.com/setup/inputs/apps -2. **Install the SDK** using CocoaPods: - -The npm install step above will automatically include our react framework and the core iOS framework in your project. However depending on your app and its other dependecies you must integrate it in 1 of 3 ways +2. **Install the SDK.** React Native and this package install with CocoaPods, and the mParticle core SDK and its kits come from Swift Package Manager, linked into your app target ([details](#swift-package-manager)). First, set the iOS deployment target to 15.6. `react-native-mparticle.podspec` declares `ios 15.6` / `tvos 15.6`, above React Native's own `min_ios_version_supported` (15.1), so set it explicitly in `ios/Podfile` — along with any app target or extension pinned lower: @@ -191,52 +187,13 @@ First, set the iOS deployment target to 15.6. `react-native-mparticle.podspec` d platform :ios, '15.6' ``` -A. Static Libraries are the React Native default, but the Apple SDK and the Rokt pods contain Swift code, so they need an exception in the form of a pre-install command in the Podfile. Apple SDK 9 split `mParticle-Apple-SDK` into `mParticle-Apple-SDK-ObjC` and `mParticle-Apple-SDK-Swift`, so the list covers both, plus the Rokt kit and its transitive pods: +List your kits by CocoaPods name at the top of `ios/Podfile`, before any `target` block: ```ruby -pre_install do |installer| - installer.pod_targets.each do |pod| - if ['mParticle-Apple-SDK', 'mParticle-Apple-SDK-ObjC', - 'mParticle-Apple-SDK-Swift', 'mParticle-Rokt', 'Rokt-Widget', - 'RoktContracts', 'RoktUXHelper', 'DcuiSchema'].include?(pod.name) - def pod.build_type; - Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework) - end - end - end -end +$RNMParticleSPMKits = ['mParticle-Rokt'] ``` -The Expo config plugin generates the same list, including the transitive Rokt pods, from `iosKits`; `sample/ios/Podfile` carries it for a bare app. - -Then run the following command - -```bash -bundle exec pod install -``` - -B&C. Frameworks are the default for Swift development and while it isn't preferred by React Native it is supported. Additionally you can define whether the frameworks are built staticly or dynamically. - -This reads `USE_FRAMEWORKS` from the environment, so your Podfile needs the block that acts on it (see `sample/ios/Podfile`): - -```ruby -linkage = ENV['USE_FRAMEWORKS'] -if linkage != nil - use_frameworks! :linkage => linkage.to_sym -end -``` - -Then run either of the following commands - -```bash -USE_FRAMEWORKS=static bundle exec pod install -``` - -or - -```bash -USE_FRAMEWORKS=dynamic bundle exec pod install -``` +Then run `bundle exec pod install`. It prints each Swift package it links, such as `[mParticle] MyApp <- mParticle-Rokt 9.6.1`; commit the `.xcodeproj` change and `Package.resolved`. Every CocoaPods linkage works, and no `pre_install` hook is needed. To take the SDKs from CocoaPods instead, see [CocoaPods (deprecated)](#cocoapods-deprecated). 3. Import and start the mParticle Apple SDK into Swift or Objective-C. @@ -262,7 +219,7 @@ For more help, see [the iOS set up docs](https://docs.mparticle.com/developers/s > reactNativeFactory = RCTReactNativeFactory(delegate: delegate) > ``` -> **If you install no iOS kit, declare the umbrella pod.** `mParticle-Apple-SDK` is now a thin Swift umbrella over `mParticle-Apple-SDK-ObjC`, and this wrapper depends on the ObjC pod directly — so the umbrella is installed only when something else declares it, as `mParticle-Rokt` 9.x does. Without a kit, add `pod 'mParticle-Apple-SDK', '>= 9.2.2', '< 10.0'` (matching this library's own floor) for `import mParticle_Apple_SDK` to resolve, or import `mParticle_Apple_SDK_ObjC` instead. +> **With CocoaPods and no iOS kit, declare the umbrella pod.** Swift Package Manager, the default, always links the core SDK, so this applies only to [CocoaPods](#cocoapods-deprecated). `mParticle-Apple-SDK` is now a thin Swift umbrella over `mParticle-Apple-SDK-ObjC`, and this wrapper depends on the ObjC pod directly — so the umbrella is installed only when something else declares it, as `mParticle-Rokt` 9.x does. Without a kit, add `pod 'mParticle-Apple-SDK', '>= 9.2.2', '< 10.0'` (matching this library's own floor) for `import mParticle_Apple_SDK` to resolve, or import `mParticle_Apple_SDK_ObjC` instead. #### Swift Example @@ -349,12 +306,12 @@ Next, you'll need to start the SDK: For standard Rokt placements, add the mParticle Rokt kit: ```ruby -pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0' +$RNMParticleSPMKits = ['mParticle-Rokt'] ``` -Kit `9.3.1` is the first release requiring `Rokt-Widget` `~> 5.3` (`9.3.0` still allows `~> 5.2`), so Rokt iOS resolves transitively from this floor — do not declare `Rokt-Widget` yourself. +With [CocoaPods](#cocoapods-deprecated), declare the pod instead: `pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0'`. Kit `9.3.1` is the first release requiring `Rokt-Widget` `~> 5.3` (`9.3.0` still allows `~> 5.2`), so Rokt iOS resolves transitively from this floor — do not declare `Rokt-Widget` yourself. -In Expo apps, use `iosKits: ["mParticle-Rokt"]` for standard Rokt placements. The Expo plugin pins `mParticle-Rokt` to `>= 9.3.1, < 10.0`. It does not add payment-extension pods or URL callback forwarding in this release. +In Expo apps, use `iosKits: ["mParticle-Rokt"]` for standard Rokt placements. With `iosDependencyManager: 'cocoapods'`, the Expo plugin pins `mParticle-Rokt` to `>= 9.3.1, < 10.0`. It does not add payment-extension pods or URL callback forwarding in this release. See [MIGRATING.md](./MIGRATING.md) for release-specific migration guidance. @@ -375,36 +332,25 @@ npm start and build your workspace from xCode. -### Swift Package Manager (opt-in) - -By default this package takes the mParticle SDKs from CocoaPods. In Swift Package Manager mode, React Native and this package still install with CocoaPods, but the mParticle core SDK and its kits come from Swift Package Manager and are linked into your app target. Use it when you add kits with Swift Package Manager, or when you need mParticle SDK releases that are not published to CocoaPods. iOS only: `$RNMParticleUseSPM` applies to the whole Podfile, so a Podfile with a tvOS target that uses this package must stay on CocoaPods. - -Every mParticle and Rokt SDK must then come from Swift Package Manager, and none from CocoaPods. If the core comes from one and a kit from the other, the app contains two copies of the SDK: it builds and archives without warnings, then crashes at runtime. - -1. At the top of `ios/Podfile`, before any `target` block: +### Swift Package Manager - ```ruby - $RNMParticleUseSPM = true - require_relative '../node_modules/react-native-mparticle/ios/mparticle_spm' - ``` +This package takes the mParticle SDKs from Swift Package Manager by default. React Native and this package still install with CocoaPods, but the mParticle core SDK and its kits are Swift packages linked into your app target. CocoaPods trunk becomes read-only on 2 December 2026 ([announcement](https://blog.cocoapods.org/CocoaPods-Specs-Repo/)), so taking the SDKs from CocoaPods is [deprecated](#cocoapods-deprecated). iOS only: a Podfile with a tvOS target that uses this package must opt out, for the whole Podfile. -2. Remove the mParticle kit pods (for example `pod 'mParticle-Rokt'`) and the mParticle entries of the `pre_install` dynamic-framework hook shown above. +Every mParticle and Rokt SDK must come from Swift Package Manager, and none from CocoaPods. If the core comes from one and a kit from the other, the app contains two copies of the SDK: it builds and archives without warnings, then crashes at runtime. -3. At the end of `post_install`, after `react_native_post_install(...)`, list your kits: +**Podfile settings.** Both are optional. Set them at the top of `ios/Podfile`, before any `target` block, which is where this package's podspec is evaluated: - ```ruby - mparticle_spm_post_install(installer, kits: [ - { url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt', version: '9.6.1' }, - ]) - ``` - - `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. +```ruby +$RNMParticleSPMKits = ['mParticle-Rokt', 'mParticle-Braze-14'] +$RNMParticleSPMCoreVersion = '9.6.1' +``` -4. Run `pod install`, then build as usual. +- `$RNMParticleSPMKits` lists kits by CocoaPods name. [`ios/mparticle_spm_kits.json`](./ios/mparticle_spm_kits.json) maps every kit of the mParticle Apple SDK to its Swift package, including `mParticle-Kochava-9` and `mParticle-Kochava-No-Tracking-9`, which ship only as Swift packages, and `RoktSDKPlus`, which already includes `mParticle-Rokt`. For any other kit, give `{ url: '…', product: '…', version: '…' }`. +- `$RNMParticleSPMCoreVersion` pins the core SDK. It defaults to the version this release was tested with. A kit without a version gets the core's, since mParticle kits are released with the core. -**What `mparticle_spm_post_install` changes.** On each `pod install` it edits your app's `.xcodeproj`: it adds a Swift package reference for the mParticle core SDK and each kit, pinned to the exact version, and links each package's product into every iOS application target that uses this package. It prints every change (`[mParticle] MyApp <- mParticle-Rokt 9.6.1`) and running it again changes nothing. It stops `pod install` with an error if a pod would add a second copy of the SDKs. Commit the `.xcodeproj` change and `ios/.xcworkspace/xcshareddata/swiftpm/Package.resolved`, so every build resolves the same versions. +**What `pod install` changes.** On each `pod install`, this package edits your app's `.xcodeproj`: it adds a Swift package reference for the core SDK and each kit, pinned to the exact version, and links each package's product into every iOS application target that uses this package. It prints every change (`[mParticle] MyApp <- mParticle-Rokt 9.6.1`), and running it again changes nothing. It stops `pod install` with an error if a pod would add a second copy of the SDKs. Commit the `.xcodeproj` change and `ios/.xcworkspace/xcshareddata/swiftpm/Package.resolved`, so every build resolves the same versions. -**Linkage.** This package's pod is always built as a static framework in this mode, so it works with each CocoaPods linkage: +**Linkage.** This package's pod is always built as a static framework, so it works with each CocoaPods linkage: | Podfile linkage | Supported | | --------------------------------------- | --------- | @@ -414,14 +360,74 @@ Every mParticle and Rokt SDK must then come from Swift Package Manager, and none **Troubleshooting.** -- `pod install` fails with `[mParticle] $RNMParticleUseSPM is set, but these pods would add a second copy of the mParticle/Rokt SDKs`: remove the pods it lists, including entries in a `pre_install` hook, and add those kits through `mparticle_spm_post_install` instead. -- `pod install` fails with `The Swift pod mParticle-Apple-SDK depends upon mParticle-Apple-SDK-ObjC, which does not define modules`: an mParticle kit pod such as `mParticle-Rokt` is still declared. CocoaPods reports this before the check above can run. Remove the kit pod and add the kit through `mparticle_spm_post_install`. -- The build fails with `[mParticle] $RNMParticleUseSPM is set but the mParticle-Apple-SDK Swift package is not linked into the app target`: `mparticle_spm_post_install` did not run, or the package was removed from the app target afterwards. Call it in `post_install` and run `pod install` again. -- A Debug build shows the red box `[mParticle] The mParticle SDK is loaded more than once`: the SDK comes from both CocoaPods and Swift Package Manager. Remove the mParticle and Rokt pods and Swift packages you added by hand, then set up one mode as described above. +- `pod install` fails with `[mParticle] This package takes the mParticle SDKs from Swift Package Manager, but these pods would add a second copy`: remove the kit pods you declared, such as `pod 'mParticle-Rokt'`, and their entries in any `pre_install` hook, then list the kits in `$RNMParticleSPMKits`. The error also lists the pods those kits pull in. To stay on CocoaPods for now, set `$RNMParticleDisableSPM = true` instead. +- `pod install` fails with `[mParticle] $RNMParticleSPMKits: unknown kit`: use a name from `ios/mparticle_spm_kits.json`, or give the kit's `url:` and `product:`. +- `pod install` fails with `[mParticle] Swift Package Manager mode is iOS only`: a tvOS target uses this package. Set `$RNMParticleDisableSPM = true` at the top of the Podfile. +- The build fails with `[mParticle] Swift Package Manager mode is on, but the mParticle-Apple-SDK Swift package is not linked into the app target`: the package was removed from the app target after `pod install`. Run `pod install` again. +- A Debug build shows the red box `[mParticle] The mParticle SDK is loaded more than once`: the SDK comes from both CocoaPods and Swift Package Manager. Remove the mParticle and Rokt pods and any Swift packages you added by hand, then run `pod install`. + +### CocoaPods (deprecated) + +To take the mParticle SDKs from CocoaPods instead, set this at the top of `ios/Podfile`, before any `target` block, and declare the kit pods, such as `pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0'`: + +```ruby +$RNMParticleDisableSPM = true +``` + +CocoaPods trunk becomes read-only on 2 December 2026, and a future release will remove this option. If the app target still links the mParticle Swift packages, `pod install` warns: remove them from the target, or the app contains two copies of the SDK. `ios/mparticle_spm_kits.json` marks the kits that have no pod. + +Depending on your app and its other dependencies, integrate the pods in one of three ways. + +A. Static Libraries are the React Native default, but the Apple SDK and the Rokt pods contain Swift code, so they need an exception in the form of a pre-install command in the Podfile. Apple SDK 9 split `mParticle-Apple-SDK` into `mParticle-Apple-SDK-ObjC` and `mParticle-Apple-SDK-Swift`, so the list covers both, plus the Rokt kit and its transitive pods: + +```ruby +pre_install do |installer| + installer.pod_targets.each do |pod| + if ['mParticle-Apple-SDK', 'mParticle-Apple-SDK-ObjC', + 'mParticle-Apple-SDK-Swift', 'mParticle-Rokt', 'Rokt-Widget', + 'RoktContracts', 'RoktUXHelper', 'DcuiSchema'].include?(pod.name) + def pod.build_type; + Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework) + end + end + end +end +``` + +With `iosDependencyManager: 'cocoapods'`, the Expo config plugin generates the same list, including the transitive Rokt pods, from `iosKits`; `sample/ios/Podfile` carries it for a bare app (`MP_USE_COCOAPODS=1`). + +Then run the following command + +```bash +bundle exec pod install +``` + +B&C. Frameworks are the default for Swift development and while it isn't preferred by React Native it is supported. Additionally you can define whether the frameworks are built staticly or dynamically. + +This reads `USE_FRAMEWORKS` from the environment, so your Podfile needs the block that acts on it (see `sample/ios/Podfile`): + +```ruby +linkage = ENV['USE_FRAMEWORKS'] +if linkage != nil + use_frameworks! :linkage => linkage.to_sym +end +``` + +Then run either of the following commands + +```bash +USE_FRAMEWORKS=static bundle exec pod install +``` + +or + +```bash +USE_FRAMEWORKS=dynamic bundle exec pod install +``` ### Experimental: React Native Swift Package Manager mode -> **Not for production.** React Native's own Swift Package Manager mode (React Native 0.87 or later) is experimental, and so is this package's support for it. Use CocoaPods, with or without the opt-in mode above, for apps you ship. +> **Not for production.** React Native's own Swift Package Manager mode (React Native 0.87 or later) is experimental, and so is this package's support for it. Use this package's default mode above, which installs React Native with CocoaPods, for apps you ship. This package ships a `Package.swift`, so `npx react-native spm add` links it without a scaffolded manifest. The app target must also link the mParticle core SDK and each kit as Swift packages, for example in Xcode (File › Add Package Dependencies): diff --git a/ios/mparticle_spm.rb b/ios/mparticle_spm.rb index 52f299b..156660d 100644 --- a/ios/mparticle_spm.rb +++ b/ios/mparticle_spm.rb @@ -1,23 +1,24 @@ -# Opt-in Swift Package Manager mode for the mParticle SDKs (see README › Swift Package Manager). +# Swift Package Manager mode for the mParticle SDKs, the default (see README › Swift Package Manager). # -# Podfile usage (bare React Native): +# react-native-mparticle.podspec loads this file on every `pod install` and turns the mode on unless +# the Podfile opts out. React Native and this package still install with CocoaPods; the mParticle +# core SDK and its kits are Swift packages linked into the app target. Podfile settings, all +# optional, set before the first `target` block, which evaluates the podspec: # -# $RNMParticleUseSPM = true # must be set before `target` / use_native_modules! -# require_relative '../node_modules/react-native-mparticle/ios/mparticle_spm' -# ... -# post_install do |installer| -# react_native_post_install(installer, ...) -# mparticle_spm_post_install(installer, kits: [ -# { url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt', version: '9.6.1' }, -# ]) -# end +# $RNMParticleSPMKits = ['mParticle-Rokt'] # kits by pod name, from ios/mparticle_spm_kits.json, +# # or { url:, product:, version: } for any other kit +# $RNMParticleSPMCoreVersion = '9.6.1' # exact core SDK version; kits without one get it too +# $RNMParticleDisableSPM = true # take the SDKs from CocoaPods instead # -# What it does: -# 1. Refuses to install when a pod would add a second copy of the mParticle / Rokt SDKs (mixing pods -# and Swift packages builds and archives silently, then crashes at runtime). -# 2. Adds the mParticle core (and any kits) as Swift packages to every iOS application target that -# uses this pod, idempotently, pinned to an exact version. The app target owns the only copy of each -# SDK. +# On each `pod install` in this mode: +# 1. Before CocoaPods validates the targets, it stops the install if a pod would add a second copy +# of the mParticle / Rokt SDKs. Mixing pods and Swift packages builds and archives silently, then +# crashes at runtime. +# 2. After the Podfile's post_install hook, it adds the core SDK and each kit as Swift packages, +# pinned to an exact version, to every iOS application target that uses this pod. The app target +# owns the only copy of each SDK. +# With the opt-out, it only warns when an app target still links the mParticle Swift packages. +require 'json' require 'xcodeproj' module MParticleSPM @@ -25,19 +26,85 @@ module MParticleSPM # package identity (mparticle-apple-sdk). No `.git` suffix for the core. CORE_URL = 'https://github.com/mParticle/mparticle-apple-sdk'.freeze CORE_PRODUCT = 'mParticle-Apple-SDK'.freeze - # Default core version when the Podfile does not pass one. Bump with each tested native release. + # Default core version when the Podfile sets none. Bump with each tested native release. DEFAULT_CORE_VERSION = '9.6.1'.freeze - CONFLICTING_PODS = /\A(mParticle-.*|Rokt-Widget|RoktContracts|RoktUXHelper|DcuiSchema)\z/.freeze + # Kits by their CocoaPods name. The Expo config plugin reads the same file. + KITS = JSON.parse(File.read(File.join(__dir__, 'mparticle_spm_kits.json')))['kits'].freeze + CONFLICTING_PODS = + /\A(mParticle-.*|Rokt-Widget|RoktContracts|RoktUXHelper|DcuiSchema|RoktSDKPlus|RoktPaymentExtension)\z/.freeze + + def self.enabled? + !(defined?($RNMParticleDisableSPM) && $RNMParticleDisableSPM) + end + + def self.core_version + (defined?($RNMParticleSPMCoreVersion) && $RNMParticleSPMCoreVersion) || DEFAULT_CORE_VERSION + end + + # $RNMParticleSPMKits as { url:, product:, version: } hashes. mParticle kits are released in + # lockstep with the core, so a kit without a version gets the core's. + def self.kits + entries = Array(defined?($RNMParticleSPMKits) ? $RNMParticleSPMKits : nil) + names = entries.grep(String) + names.each do |name| + included = Array(KITS.dig(name, 'includes')) & names + next if included.empty? + + raise Pod::Informative, + "[mParticle] $RNMParticleSPMKits lists #{name} and #{included.join(', ')}, but #{name} " \ + "already includes #{included.join(', ')}. List only #{name}." + end + entries.map { |entry| resolve_kit(entry) } + end + + def self.resolve_kit(entry) + if entry.is_a?(String) + kit = KITS[entry] + unless kit + raise Pod::Informative, + "[mParticle] $RNMParticleSPMKits: unknown kit #{entry.inspect}. Use a pod name from " \ + 'react-native-mparticle/ios/mparticle_spm_kits.json, or { url:, product:, version: } ' \ + 'for any other kit.' + end + return { url: kit['url'], product: kit['product'], version: core_version } + end + + kit = entry.is_a?(Hash) ? entry.transform_keys(&:to_sym) : {} + unless kit[:url] && kit[:product] + raise Pod::Informative, + "[mParticle] $RNMParticleSPMKits: #{entry.inspect} must be a kit pod name, or a hash " \ + 'with url: and product: (and optionally version:).' + end + { version: core_version }.merge(kit.slice(:url, :product, :version)) + end + + # Runs before CocoaPods resolves the platforms, which would otherwise fail on a tvOS target with a + # generic "not compatible" error. + def self.check_podfile!(podfile) + return unless podfile + + tvos = podfile.target_definition_list.select do |definition| + definition.platform&.name == :tvos && + definition.dependencies.any? { |dependency| dependency.root_name == 'react-native-mparticle' } + end + return if tvos.empty? + + raise Pod::Informative, + "[mParticle] Swift Package Manager mode is iOS only, but #{tvos.map(&:name).join(', ')} " \ + 'targets tvOS. Set $RNMParticleDisableSPM = true at the top of the Podfile to take the ' \ + 'mParticle SDKs from CocoaPods for the whole Podfile.' + end def self.guard!(installer) conflicts = installer.pod_targets.map(&:pod_name).uniq.grep(CONFLICTING_PODS) return if conflicts.empty? raise Pod::Informative, - "[mParticle] $RNMParticleUseSPM is set, but these pods would add a second copy of the " \ - "mParticle/Rokt SDKs next to the Swift packages: #{conflicts.sort.join(', ')}. Remove them " \ - "(including any mParticle pre_install dynamic-framework hook entries) and add kits via " \ - "mparticle_spm_post_install(installer, kits: [...]) instead." + '[mParticle] This package takes the mParticle SDKs from Swift Package Manager, but these ' \ + "pods would add a second copy: #{conflicts.sort.join(', ')}. Either remove them (and their " \ + 'entries in any pre_install dynamic-framework hook) and list the kits in ' \ + '$RNMParticleSPMKits, or set $RNMParticleDisableSPM = true at the top of the Podfile to stay ' \ + 'on CocoaPods.' end # Only iOS apps that use this pod: other apps in the same Podfile must not get the SDKs. @@ -76,20 +143,65 @@ def self.add_package(project, target, url:, product:, version:) target.frameworks_build_phase.files << build_file true end -end -def mparticle_spm_post_install(installer, core_version: MParticleSPM::DEFAULT_CORE_VERSION, kits: []) - MParticleSPM.guard!(installer) - # 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| - added = MParticleSPM.add_package(project, target, **pkg.slice(:url, :product, :version)) - Pod::UI.puts "[mParticle] #{target.name} <- #{pkg[:product]} #{pkg[:version]}".green if added + def self.link_packages(installer) + packages = [{ url: CORE_URL, product: CORE_PRODUCT, version: core_version }] + kits + touched = {} + application_targets(installer).each do |project, target| + packages.each do |pkg| + added = add_package(project, target, **pkg.slice(:url, :product, :version)) + Pod::UI.puts "[mParticle] #{target.name} <- #{pkg[:product]} #{pkg[:version]}".green if added + end + touched[project.path.to_s] = project end - touched[project.path.to_s] = project + touched.each_value(&:save) end - touched.each_value(&:save) + + # With the opt-out, the CocoaPods SDK and any leftover Swift package would both be linked. Kits + # outside the table are caught by their `mParticle-` product name. + def self.warn_leftover_packages(installer) + products = [CORE_PRODUCT] + KITS.values.map { |kit| kit['product'] } + application_targets(installer).each do |_, target| + leftover = target.package_product_dependencies.map(&:product_name).select do |product| + products.include?(product) || product.start_with?('mParticle-') + end + next if leftover.empty? + + Pod::UI.warn "[mParticle] $RNMParticleDisableSPM is set, but #{target.name} still links the " \ + "Swift packages #{leftover.join(', ')}. Remove them from the target, or the app " \ + 'contains two copies of the SDK.' + end + end + + # Prepended to Pod::Installer when the Podfile evaluates the podspec (React Native's + # use_native_modules! does), before the install starts, so no Podfile hook is needed. + module InstallerHooks + private + + def resolve_dependencies + MParticleSPM.check_podfile!(podfile) if MParticleSPM.enabled? + super + end + + def validate_targets + if MParticleSPM.enabled? + MParticleSPM.guard!(self) + MParticleSPM.kits + end + super + end + + def run_podfile_post_install_hooks + super + if MParticleSPM.enabled? + MParticleSPM.link_packages(self) + else + MParticleSPM.warn_leftover_packages(self) + end + end + end +end + +if defined?(Pod::Installer) && !Pod::Installer.ancestors.include?(MParticleSPM::InstallerHooks) + Pod::Installer.prepend(MParticleSPM::InstallerHooks) end diff --git a/ios/mparticle_spm_kits.json b/ios/mparticle_spm_kits.json new file mode 100644 index 0000000..c5a05f2 --- /dev/null +++ b/ios/mparticle_spm_kits.json @@ -0,0 +1,134 @@ +{ + "kits": { + "mParticle-Adjust-5": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-adjust-5", + "product": "mParticle-Adjust" + }, + "mParticle-Adobe-5": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-adobe-5", + "product": "mParticle-Adobe" + }, + "mParticle-AppsFlyer-6": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-appsflyer-6", + "product": "mParticle-AppsFlyer" + }, + "mParticle-Apptentive-6": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-apptentive-6", + "product": "mParticle-Apptentive" + }, + "mParticle-Apptentive-7": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-apptentive-7", + "product": "mParticle-Apptentive" + }, + "mParticle-Apptimize-3": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-apptimize-3", + "product": "mParticle-Apptimize" + }, + "mParticle-BranchMetrics-3": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-branchmetrics-3", + "product": "mParticle-BranchMetrics" + }, + "mParticle-Braze-12": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-braze-12", + "product": "mParticle-Braze" + }, + "mParticle-Braze-13": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-braze-13", + "product": "mParticle-Braze" + }, + "mParticle-Braze-14": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-braze-14", + "product": "mParticle-Braze" + }, + "mParticle-Braze-17": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-braze-17", + "product": "mParticle-Braze" + }, + "mParticle-CleverTap-7": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-clevertap-7", + "product": "mParticle-CleverTap" + }, + "mParticle-ComScore-6": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-comscore-6", + "product": "mParticle-ComScore" + }, + "mParticle-Firebase-11": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-google-analytics-firebase-11", + "product": "mParticle-Firebase" + }, + "mParticle-Firebase-12": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-google-analytics-firebase-12", + "product": "mParticle-Firebase" + }, + "mParticle-FirebaseGA4-11": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-google-analytics-firebase-ga4-11", + "product": "mParticle-FirebaseGA4" + }, + "mParticle-FirebaseGA4-12": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-google-analytics-firebase-ga4-12", + "product": "mParticle-FirebaseGA4" + }, + "mParticle-Iterable-6": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-iterable-6", + "product": "mParticle-Iterable" + }, + "mParticle-Kochava-9": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-kochava-9", + "product": "mParticle-Kochava" + }, + "mParticle-Kochava-No-Tracking-9": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-kochava-no-tracking-9", + "product": "mParticle-Kochava" + }, + "mParticle-Leanplum-6": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-leanplum-6", + "product": "mParticle-Leanplum" + }, + "mParticle-Localytics-6": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-localytics-6", + "product": "mParticle-Localytics" + }, + "mParticle-Localytics-7": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-localytics-7", + "product": "mParticle-Localytics" + }, + "mParticle-OneTrust": { + "url": "https://github.com/mparticle-integrations/mp-apple-integration-onetrust", + "product": "mParticle-OneTrust" + }, + "mParticle-Optimizely-4": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-optimizely-4", + "product": "mParticle-Optimizely" + }, + "mParticle-Optimizely-5": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-optimizely-5", + "product": "mParticle-Optimizely" + }, + "mParticle-Radar-3": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-radar-3", + "product": "mParticle-Radar" + }, + "mParticle-Rokt": { + "url": "https://github.com/mparticle-integrations/mp-apple-integration-rokt", + "product": "mParticle-Rokt" + }, + "mParticle-Singular-12": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-singular-12", + "product": "mParticle-Singular" + }, + "mParticle-UrbanAirship-19": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-urbanairship-19", + "product": "mParticle-UrbanAirship" + }, + "mParticle-UrbanAirship-20": { + "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-urbanairship-20", + "product": "mParticle-UrbanAirship" + }, + "RoktSDKPlus": { + "url": "https://github.com/ROKT/rokt-sdk-plus-ios", + "product": "RoktSDKPlus", + "includes": ["mParticle-Rokt"] + } + }, + "swiftPackageOnly": ["mParticle-Kochava-9", "mParticle-Kochava-No-Tracking-9"] +} diff --git a/js/__tests__/plugin-ios-spm.test.ts b/js/__tests__/plugin-ios-spm.test.ts index 7a6b3bd..4db011c 100644 --- a/js/__tests__/plugin-ios-spm.test.ts +++ b/js/__tests__/plugin-ios-spm.test.ts @@ -1,10 +1,12 @@ +import * as fs from 'fs'; +import * as path from 'path'; 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 Expo config plugin's Podfile changes. Swift Package Manager (the default) writes only the + * settings ios/mparticle_spm.rb reads; `iosDependencyManager: 'cocoapods'` opts out and keeps + * producing the pods and pre_install hook earlier releases produced. */ // The part of the Expo prebuild template the plugin touches. @@ -31,6 +33,10 @@ target 'MyApp' do end `; +const START = + '# mParticle Swift Package Manager settings (added by react-native-mparticle expo plugin)'; +const END = '# end of mParticle Swift Package Manager settings'; + const baseProps: MParticlePluginProps = { iosApiKey: 'key', iosApiSecret: 'secret', @@ -39,67 +45,42 @@ const baseProps: MParticlePluginProps = { iosKits: ['mParticle-Rokt'], }; -const spmProps: MParticlePluginProps = { +const cocoaPodsProps: MParticlePluginProps = { ...baseProps, - iosDependencyManager: 'spm', + iosDependencyManager: 'cocoapods', }; -const ROKT_CALL = - " mparticle_spm_post_install(installer, kits: [{ url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt' }])"; +function withSettings(...lines: string[]): string { + return EXPO_PODFILE.replace( + "target 'MyApp' do", + `${[START, ...lines, END].join('\n')}\n\ntarget 'MyApp' do` + ); +} 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); + it('defaults to Swift Package Manager and writes only the kit settings', () => { + const podfile = applyMParticlePodfileMods(EXPO_PODFILE, baseProps); 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` - ) + withSettings("$RNMParticleSPMKits = ['mParticle-Rokt']") ); expect(podfile).not.toContain('pre_install'); expect(podfile).not.toContain("pod 'mParticle-Rokt'"); }); + it('writes an empty kit list when no kits are set', () => { + expect( + applyMParticlePodfileMods(EXPO_PODFILE, { + ...baseProps, + iosKits: undefined, + }) + ).toBe(withSettings('$RNMParticleSPMKits = []')); + }); + it('passes the core version and extra kits through', () => { const podfile = applyMParticlePodfileMods(EXPO_PODFILE, { - ...spmProps, + ...baseProps, + iosKits: ['mParticle-Rokt', 'mParticle-Braze-14'], iosSdkVersion: '9.6.1', iosSpmKits: [ { @@ -110,43 +91,92 @@ target 'MyApp' do` ], }); - 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' }])" + expect(podfile).toBe( + withSettings( + "$RNMParticleSPMKits = ['mParticle-Rokt', 'mParticle-Braze-14', { url: 'https://github.com/example/kit', product: 'Example-Kit', version: '1.2.3' }]", + "$RNMParticleSPMCoreVersion = '9.6.1'" + ) ); }); - it('is idempotent, and a rerun with new settings replaces the call', () => { - const once = applyMParticlePodfileMods(EXPO_PODFILE, spmProps); - expect(applyMParticlePodfileMods(once, spmProps)).toBe(once); + it('is idempotent, and a rerun with new settings replaces them', () => { + const once = applyMParticlePodfileMods(EXPO_PODFILE, baseProps); + expect(applyMParticlePodfileMods(once, baseProps)).toBe(once); const pinned = applyMParticlePodfileMods(once, { - ...spmProps, + ...baseProps, 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'"); + expect(pinned.split(START)).toHaveLength(2); + expect(pinned).toBe( + withSettings( + "$RNMParticleSPMKits = ['mParticle-Rokt']", + "$RNMParticleSPMCoreVersion = '9.7.0'" + ) + ); + }); + + it('opts out with cocoapods and keeps the pods output', () => { + expect(applyMParticlePodfileMods(EXPO_PODFILE, cocoaPodsProps)).toBe( + withSettings('$RNMParticleDisableSPM = true') + .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('rejects an iosKits name with no known Swift package', () => { expect(() => applyMParticlePodfileMods(EXPO_PODFILE, { - ...spmProps, + ...baseProps, iosKits: ['mParticle-Amplitude'], }) - ).toThrow(/"mParticle-Amplitude" has no known Swift package.*iosSpmKits/); + ).toThrow( + /"mParticle-Amplitude" has no known Swift package.*iosSpmKits.*"cocoapods"/ + ); + }); + + it('rejects a Swift-package-only kit with cocoapods', () => { + expect(() => + applyMParticlePodfileMods(EXPO_PODFILE, { + ...cocoaPodsProps, + iosKits: ['mParticle-Kochava-9'], + }) + ).toThrow(/mParticle-Kochava-9 ships only as a Swift package/); }); it('rejects values that would break out of the generated Ruby string', () => { expect(() => applyMParticlePodfileMods(EXPO_PODFILE, { - ...spmProps, + ...baseProps, iosSdkVersion: "9.6.1'); system('echo", }) ).toThrow(/invalid iosSdkVersion/); expect(() => applyMParticlePodfileMods(EXPO_PODFILE, { - ...spmProps, + ...baseProps, iosSpmKits: [{ url: 'http://example.com/kit', product: 'Kit' }], }) ).toThrow(/invalid Swift package URL/); @@ -158,6 +188,33 @@ target 'MyApp' do` ...baseProps, iosDependencyManager: 'carthage' as 'spm', }) - ).toThrow(/must be "cocoapods" or "spm"/); + ).toThrow(/must be "spm" or "cocoapods"/); + }); +}); + +describe('ios/mparticle_spm_kits.json', () => { + const table = JSON.parse( + fs.readFileSync( + path.join(__dirname, '..', '..', 'ios', 'mparticle_spm_kits.json'), + 'utf-8' + ) + ); + + it('gives every kit a GitHub package URL and a product the Podfile can quote', () => { + for (const [name, kit] of Object.entries<{ url: string; product: string }>( + table.kits + )) { + expect(`${name} ${kit.url}`).toMatch( + /^[A-Za-z0-9._+-]+ https:\/\/github\.com\/[A-Za-z0-9._/-]+$/ + ); + expect(kit.url).not.toMatch(/\.git$/); + expect(kit.product).toMatch(/^[A-Za-z0-9._+-]+$/); + } + }); + + it('lists every Swift-package-only kit as a kit', () => { + for (const name of table.swiftPackageOnly) { + expect(table.kits).toHaveProperty([name]); + } }); }); diff --git a/plugin/src/withMParticle.ts b/plugin/src/withMParticle.ts index 7399828..1357bed 100644 --- a/plugin/src/withMParticle.ts +++ b/plugin/src/withMParticle.ts @@ -5,7 +5,7 @@ import { withMParticleAndroid } from './withMParticleAndroid'; const pkg = require('../../package.json'); /** - * A kit taken from Swift Package Manager (`iosDependencyManager: 'spm'`) + * A kit taken from Swift Package Manager that is not in `iosKits`' list of known kits */ export interface IosSpmKit { /** Package repository URL, e.g. `https://github.com/mparticle-integrations/mp-apple-integration-rokt` */ @@ -63,28 +63,29 @@ export interface MParticlePluginProps { dataPlanVersion?: number; /** - * iOS kit pod names to include - * @example ['mParticle-Rokt', 'mParticle-Amplitude'] + * iOS kits, by CocoaPods name. With Swift Package Manager (the default), each must be one of the + * kits in `ios/mparticle_spm_kits.json`; list any other kit in `iosSpmKits`. + * @example ['mParticle-Rokt', 'mParticle-Braze-14'] */ 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' + * - `'cocoapods'`: pods. Deprecated: CocoaPods trunk becomes read-only on 2 December 2026. + * @default 'spm' */ iosDependencyManager?: 'cocoapods' | 'spm'; /** - * In `'spm'` mode, the exact mParticle core SDK version, also used for kits without a version. + * With Swift Package Manager, 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. + * With Swift Package Manager, kits that `iosKits` does not know, as Swift packages. * @example [{ url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt' }] */ iosSpmKits?: IosSpmKit[]; diff --git a/plugin/src/withMParticleIOS.ts b/plugin/src/withMParticleIOS.ts index e8c448f..07a9fcf 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 { IosSpmKit, MParticlePluginProps } from './withMParticle'; +import { MParticlePluginProps } from './withMParticle'; import { getCustomBaseUrl } from './customBaseUrl'; import * as fs from 'fs'; import * as path from 'path'; @@ -423,17 +423,29 @@ function getKitPodDeclaration(kit: string): string { } /** - * Swift packages for the `iosKits` names that have one, for `iosDependencyManager: 'spm'`. + * The kits Swift Package Manager mode knows by CocoaPods name. The pod's Podfile helper reads the same + * file, so the two cannot disagree. */ -const KIT_SWIFT_PACKAGES: Record = { - 'mParticle-Rokt': { - url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', - product: 'mParticle-Rokt', - }, -}; +interface SpmKitTable { + kits: Record; + swiftPackageOnly: string[]; +} + +function readSpmKitTable(): SpmKitTable { + // plugin/src or plugin/build → the package root. + const file = path.join( + __dirname, + '..', + '..', + 'ios', + 'mparticle_spm_kits.json' + ); + return JSON.parse(fs.readFileSync(file, 'utf-8')); +} -const SPM_MARKER = - '# mParticle Swift Package Manager mode (added by react-native-mparticle expo plugin)'; +const SPM_START = + '# mParticle Swift Package Manager settings (added by react-native-mparticle expo plugin)'; +const SPM_END = '# end of mParticle Swift Package Manager settings'; // 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._~/-]+$/; @@ -448,93 +460,65 @@ function rubyString(value: string, pattern: RegExp, what: string): string { return `'${value}'`; } -function getSpmKits(props: MParticlePluginProps): IosSpmKit[] { - const mapped = (props.iosKits ?? []).map(kit => { - const pkg = KIT_SWIFT_PACKAGES[kit]; - if (!pkg) { +function getSpmSettings(props: MParticlePluginProps): string[] { + const table = readSpmKitTable(); + const names = (props.iosKits ?? []).map(kit => { + if (!table.kits[kit]) { 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.' + 'List it in iosSpmKits as { url, product, version }, or set iosDependencyManager to "cocoapods".' ); } - return pkg; + return rubyString(kit, SPM_NAME, 'iosKits entry'); }); - return [...mapped, ...(props.iosSpmKits ?? [])]; -} - -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( + const custom = (props.iosSpmKits ?? []).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(', ')} }`; + }); + const lines = [`$RNMParticleSPMKits = [${[...names, ...custom].join(', ')}]`]; + if (props.iosSdkVersion) { + lines.push( + `$RNMParticleSPMCoreVersion = ${rubyString( props.iosSdkVersion, SPM_NAME, 'iosSdkVersion' )}` - : ''; - return `mparticle_spm_post_install(installer${coreVersion}, kits: [${kits}])`; + ); + } + return lines; } /** - * `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. + * Writes this plugin's Podfile settings above the first target block, which evaluates the podspec. + * A second prebuild replaces them, so changed versions or kits are picked up. */ -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}`); +function setSpmSettings(podfileContent: string, lines: string[]): string { + const block = [SPM_START, ...lines, SPM_END, ''].join('\n'); + const existing = new RegExp( + `${escapeRegExp(SPM_START)}\\n[\\s\\S]*?${escapeRegExp(SPM_END)}\\n` + ); + if (existing.test(podfileContent)) { + return podfileContent.replace(existing, () => block); } - const postInstall = /post_install do \|installer\|\n/; - if (!postInstall.test(contents)) { + const firstTarget = /^target /m; + if (!firstTarget.test(podfileContent)) { throw new Error( - 'react-native-mparticle plugin: no post_install block found in ios/Podfile' + 'react-native-mparticle plugin: no target block found in ios/Podfile' ); } - return contents.replace(postInstall, match => `${match} ${call}\n`); + return podfileContent.replace(firstTarget, match => `${block}\n${match}`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** @@ -544,18 +528,34 @@ export function applyMParticlePodfileMods( podfileContent: string, props: MParticlePluginProps ): string { - const manager = props.iosDependencyManager ?? 'cocoapods'; + const manager = props.iosDependencyManager ?? 'spm'; if (manager === 'spm') { - return applySpmPodfileMods(podfileContent, props); + // The pod's Podfile helper links the core SDK and these kits; no pods or pre_install hook. + return setSpmSettings(podfileContent, getSpmSettings(props)); } if (manager !== 'cocoapods') { throw new Error( - `react-native-mparticle plugin: iosDependencyManager must be "cocoapods" or "spm", got ${JSON.stringify( + `react-native-mparticle plugin: iosDependencyManager must be "spm" or "cocoapods", got ${JSON.stringify( manager )}` ); } + const swiftPackageOnly = (props.iosKits ?? []).filter(kit => + readSpmKitTable().swiftPackageOnly.includes(kit) + ); + if (swiftPackageOnly.length > 0) { + throw new Error( + `react-native-mparticle plugin: ${swiftPackageOnly.join( + ', ' + )} ships only as a Swift package, ` + + 'so it needs iosDependencyManager "spm".' + ); + } + podfileContent = setSpmSettings(podfileContent, [ + '$RNMParticleDisableSPM = true', + ]); + // 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) diff --git a/react-native-mparticle.podspec b/react-native-mparticle.podspec index 90bfaa3..a6d632e 100644 --- a/react-native-mparticle.podspec +++ b/react-native-mparticle.podspec @@ -1,4 +1,5 @@ require 'json' +require_relative 'ios/mparticle_spm' ios_platform = '15.6' @@ -24,10 +25,10 @@ Pod::Spec.new do |s| s.private_header_files = 'ios/**/*.h' xcconfig = { 'DEFINES_MODULE' => 'YES' } - # Opt-in Swift Package Manager mode: set `$RNMParticleUseSPM = true` at the top of the Podfile and - # call `mparticle_spm_post_install` (ios/mparticle_spm.rb) in post_install. When it is unset, this - # podspec resolves exactly as before. - use_spm = defined?($RNMParticleUseSPM) && $RNMParticleUseSPM + # Swift Package Manager mode is the default: ios/mparticle_spm.rb links the mParticle SDKs into the + # app target on each `pod install`. `$RNMParticleDisableSPM = true` at the top of the Podfile takes + # them from CocoaPods instead. + use_spm = MParticleSPM.enabled? if use_spm # The app target links mParticle and RoktContracts as Swift packages. This pod only compiles @@ -53,7 +54,7 @@ Pod::Spec.new do |s| MAP="${OBJROOT}/GeneratedModuleMaps-${PLATFORM_NAME}/mParticle_Apple_SDK_ObjC.modulemap" INC=$(sed -n 's/^umbrella "\(.*\)"$/\1/p' "$MAP" 2>/dev/null || true) if [ -z "$INC" ] || [ ! -d "$INC" ]; then - echo "error: [mParticle] \$RNMParticleUseSPM is set but the mParticle-Apple-SDK Swift package is not linked into the app target. Call mparticle_spm_post_install in your Podfile post_install." + echo "error: [mParticle] Swift Package Manager mode is on, but the mParticle-Apple-SDK Swift package is not linked into the app target. Run pod install again, or set \$RNMParticleDisableSPM = true at the top of the Podfile to take the SDK from CocoaPods." exit 1 fi mkdir -p "${DERIVED_FILE_DIR}" diff --git a/sample/ios/Podfile b/sample/ios/Podfile index 23804f8..116b9ec 100644 --- a/sample/ios/Podfile +++ b/sample/ios/Podfile @@ -9,15 +9,15 @@ require Pod::Executable.execute_command('node', ['-p', platform :ios, '15.6' prepare_react_native_project! -# MP_USE_SPM=1 takes the mParticle SDKs from Swift Package Manager instead of CocoaPods -# (README › Swift Package Manager). Must be set before `target`, which evaluates the podspec. -# It adds the Swift packages to MParticleSample.xcodeproj: discard that change before switching back -# to CocoaPods, or the app links both copies (Debug builds then show a red box). -$RNMParticleUseSPM = ENV['MP_USE_SPM'] == '1' -require_relative '../node_modules/react-native-mparticle/ios/mparticle_spm' if $RNMParticleUseSPM -ROKT_KIT = { url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt', version: '9.6.1' }.freeze +# The mParticle SDKs come from Swift Package Manager by default (README › Swift Package Manager); +# MP_USE_COCOAPODS=1 takes them from CocoaPods instead. Both settings must come before `target`, +# which evaluates the podspec. Swift Package Manager mode adds the packages to +# MParticleSample.xcodeproj: discard that change before switching to CocoaPods, or the app links both +# copies (Debug builds then show a red box). +$RNMParticleDisableSPM = ENV['MP_USE_COCOAPODS'] == '1' +$RNMParticleSPMKits = ['mParticle-Rokt'] -unless $RNMParticleUseSPM +if $RNMParticleDisableSPM 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' @@ -48,7 +48,7 @@ target 'MParticleSample' do # 5.3 resolves RoktUXHelper 1.0.0, which requires an exact `DcuiSchema` version, so the # schema can no longer float out of sync with the RoktUXHelper sources (the desync that # the previous 2.7.0 pin guarded against). - pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0' unless $RNMParticleUseSPM + pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0' if $RNMParticleDisableSPM target 'MParticleSampleTests' do inherit! :complete @@ -63,8 +63,7 @@ target 'MParticleSample' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) - if $RNMParticleUseSPM - mparticle_spm_post_install(installer, kits: [ROKT_KIT]) + unless $RNMParticleDisableSPM # The unit tests compile against the SDK headers but never link the SDK: the host app does. # Point them at the headers the react-native-mparticle pod's script phase located. spm_headers = '"$(OBJROOT)/Pods.build/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/react-native-mparticle.build/DerivedSources/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"'