From 7534836d6a84a450856b13ee43b9e5b138f7ecb3 Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:05:55 -0400 Subject: [PATCH 1/8] feat(ios): warn in debug builds when two mParticle SDK copies are loaded (#421) Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/pull-request.yml | 1 + README.md | 4 ++ ios/RNMParticle/RNMParticle.mm | 63 +++++++++++++++++++ .../MParticleSample.xcodeproj/project.pbxproj | 4 ++ .../RNMParticleDuplicateSDKTests.m | 35 +++++++++++ 5 files changed, 107 insertions(+) create mode 100644 sample/ios/MParticleSampleTests/RNMParticleDuplicateSDKTests.m diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index e512eb9..31cdf43 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -175,6 +175,7 @@ jobs: test \ -only-testing:MParticleSampleTests/RCTConvertCommerceMappingTests \ -only-testing:MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests \ + -only-testing:MParticleSampleTests/RNMParticleDuplicateSDKTests \ | bundle exec xcpretty -k pr-notify: diff --git a/README.md b/README.md index b7774f7..9a07862 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,10 @@ npm start and build your workspace from xCode. +### Swift Package Manager + +Take the mParticle SDK and its kits from one dependency manager only. If the core comes from CocoaPods (through this package) and a kit such as `mParticle-Rokt` is added to the app with Swift Package Manager, the app contains two copies of the SDK. It builds and archives without warnings, then crashes at runtime. Debug builds detect this and show a red box: `[mParticle] The mParticle SDK is loaded more than once`. To fix it, remove the mParticle and Rokt Swift packages from the app and add the kits with CocoaPods, as shown above. + ## Android (Manual Setup) 1. Copy your mParticle key and secret from [your workspace's dashboard](https://app.mparticle.com/setup/inputs/apps) and construct an `MParticleOptions` object. diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index da15077..01529f1 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -9,6 +9,10 @@ #import #endif #import +#import +#if DEBUG +#import +#endif #ifdef RCT_NEW_ARCH_ENABLED #import @@ -143,6 +147,65 @@ + (void)load { RCTRegisterModule(self); } +#if DEBUG +- (instancetype)init +{ + if (self = [super init]) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + [RNMParticle warnIfSDKLoadedTwice]; + }); + } + return self; +} + +// Counts, per class name, the loaded images inside the app bundle that define it. System +// images are skipped: scanning them all takes tens of seconds, the bundle takes milliseconds. ++ (NSDictionary *)imageCountsForClassNames:(NSArray *)classNames +{ + NSMutableDictionary *counts = [NSMutableDictionary dictionary]; + for (NSString *name in classNames) { + counts[name] = @0; + } + NSString *bundlePath = NSBundle.mainBundle.bundlePath; + unsigned int imageCount = 0; + const char **images = objc_copyImageNames(&imageCount); + for (unsigned int i = 0; i < imageCount; i++) { + if (![@(images[i]) hasPrefix:bundlePath]) { + continue; + } + unsigned int classCount = 0; + const char **classes = objc_copyClassNamesForImage(images[i], &classCount); + for (unsigned int j = 0; j < classCount; j++) { + NSString *name = @(classes[j]); + if (counts[name] != nil) { + counts[name] = @(counts[name].unsignedIntegerValue + 1); + } + } + free(classes); + } + free(images); + return counts; +} + +// Mixing CocoaPods and Swift Package Manager for the mParticle SDKs links a second copy that +// builds and archives silently, then crashes at runtime. Say so early in development. ++ (void)warnIfSDKLoadedTwice +{ + NSArray *classNames = @[ @"MParticle", @"RoktEmbeddedView" ]; + NSDictionary *counts = [self imageCountsForClassNames:classNames]; + NSMutableArray *duplicates = [NSMutableArray array]; + for (NSString *name in classNames) { + if (counts[name].unsignedIntegerValue > 1) { + [duplicates addObject:[NSString stringWithFormat:@"%@ x%@", name, counts[name]]]; + } + } + if (duplicates.count > 0) { + RCTLogError(@"[mParticle] The mParticle SDK is loaded more than once (%@). This happens when the SDK comes from both CocoaPods and Swift Package Manager. See README › Swift Package Manager.", [duplicates componentsJoinedByString:@", "]); + } +} +#endif + RCT_EXPORT_METHOD(upload) { [[MParticle sharedInstance] upload]; diff --git a/sample/ios/MParticleSample.xcodeproj/project.pbxproj b/sample/ios/MParticleSample.xcodeproj/project.pbxproj index 7c899c9..127cf13 100644 --- a/sample/ios/MParticleSample.xcodeproj/project.pbxproj +++ b/sample/ios/MParticleSample.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ B7C10E912E50AA1100000002 /* RCTConvertCommerceMappingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */; }; B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */; }; B7C10E952E50AA1100000006 /* RoktNativeLayoutComponentViewSizingTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */; }; + B7C10E972E50AA1100000008 /* RNMParticleDuplicateSDKTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */; }; B7C10E992F6B000000000009 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E982F6B000000000008 /* SceneDelegate.swift */; }; /* End PBXBuildFile section */ @@ -49,6 +50,7 @@ B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTConvertCommerceMappingTests.m; sourceTree = ""; }; B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktPlaceholderTests.m; sourceTree = ""; }; B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RoktNativeLayoutComponentViewSizingTests.mm; sourceTree = ""; }; + B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMParticleDuplicateSDKTests.m; sourceTree = ""; }; B7C10E982F6B000000000008 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = MParticleSample/SceneDelegate.swift; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -80,6 +82,7 @@ B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */, B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */, B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */, + B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */, 00E356F01AD99517003FC87E /* Supporting Files */, ); path = MParticleSampleTests; @@ -399,6 +402,7 @@ B7C10E912E50AA1100000002 /* RCTConvertCommerceMappingTests.m in Sources */, B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */, B7C10E952E50AA1100000006 /* RoktNativeLayoutComponentViewSizingTests.mm in Sources */, + B7C10E972E50AA1100000008 /* RNMParticleDuplicateSDKTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/sample/ios/MParticleSampleTests/RNMParticleDuplicateSDKTests.m b/sample/ios/MParticleSampleTests/RNMParticleDuplicateSDKTests.m new file mode 100644 index 0000000..42d4ee3 --- /dev/null +++ b/sample/ios/MParticleSampleTests/RNMParticleDuplicateSDKTests.m @@ -0,0 +1,35 @@ +#import +#import "../../../ios/RNMParticle/RNMParticle.h" + +// Implemented in RNMParticle.mm, Debug builds only. +@interface RNMParticle (DuplicateSDKTests) ++ (NSDictionary *)imageCountsForClassNames:(NSArray *)classNames; +@end + +/** + * Guards the Debug-only check that warns when two copies of the mParticle SDK are loaded. + * + * Only `MParticle` is asserted: it lives in the SDK framework, which this test bundle shares + * with the app it hosts. The wrapper's own classes are linked into this bundle a second time + * (see RNMPRoktPlaceholderTests.m), so counting them would test the test linkage. + */ +@interface RNMParticleDuplicateSDKTests : XCTestCase +@end + +@implementation RNMParticleDuplicateSDKTests + +- (void)testCountsOneCopyOfTheSDK +{ + NSDictionary *counts = [RNMParticle imageCountsForClassNames:@[ @"MParticle" ]]; + + XCTAssertEqualObjects(counts[@"MParticle"], @1); +} + +- (void)testCountsZeroForAClassNoImageDefines +{ + NSDictionary *counts = [RNMParticle imageCountsForClassNames:@[ @"RNMPNoSuchClass" ]]; + + XCTAssertEqualObjects(counts[@"RNMPNoSuchClass"], @0); +} + +@end From 23b485161eb9d45072d67276e3c79f8cb95c2db4 Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:35:46 -0400 Subject: [PATCH 2/8] refactor(ios): resolve mParticle and RoktContracts headers through one private header (#422) Co-authored-by: Claude Opus 5.5 (1M context) --- ios/RNMParticle.xcodeproj/project.pbxproj | 2 ++ ios/RNMParticle/RNMPRokt.mm | 17 +---------- ios/RNMParticle/RNMPSDKImports.h | 28 +++++++++++++++++++ ios/RNMParticle/RNMParticle.mm | 10 +------ ios/RNMParticle/RoktEventManager.mm | 8 +----- ios/RNMParticle/RoktLayoutManager.m | 7 +---- .../RoktNativeLayoutComponentView.h | 11 +------- 7 files changed, 35 insertions(+), 48 deletions(-) create mode 100644 ios/RNMParticle/RNMPSDKImports.h diff --git a/ios/RNMParticle.xcodeproj/project.pbxproj b/ios/RNMParticle.xcodeproj/project.pbxproj index f1373fa..6e7405e 100644 --- a/ios/RNMParticle.xcodeproj/project.pbxproj +++ b/ios/RNMParticle.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ B39BCD932E30562400FC90B8 /* RoktLayoutManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RoktLayoutManager.m; sourceTree = ""; }; C4A1D2E32F6A000100ABCDEF /* RoktPlaceholderRegistry.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RoktPlaceholderRegistry.h; sourceTree = ""; }; C4A1D2E42F6A000100ABCDEF /* RoktPlaceholderRegistry.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RoktPlaceholderRegistry.m; sourceTree = ""; }; + C4A1D2E62F6A000100ABCDEF /* RNMPSDKImports.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNMPSDKImports.h; sourceTree = ""; }; B39BCD942E30562400FC90B8 /* RoktNativeLayoutComponentView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RoktNativeLayoutComponentView.h; sourceTree = ""; }; B39BCD952E30562400FC90B8 /* RoktNativeLayoutComponentView.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RoktNativeLayoutComponentView.mm; sourceTree = ""; }; DBDF24D71E007EB1000F3D73 /* libRNMParticle.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNMParticle.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -79,6 +80,7 @@ B39BCD932E30562400FC90B8 /* RoktLayoutManager.m */, C4A1D2E32F6A000100ABCDEF /* RoktPlaceholderRegistry.h */, C4A1D2E42F6A000100ABCDEF /* RoktPlaceholderRegistry.m */, + C4A1D2E62F6A000100ABCDEF /* RNMPSDKImports.h */, B39BCD942E30562400FC90B8 /* RoktNativeLayoutComponentView.h */, B39BCD952E30562400FC90B8 /* RoktNativeLayoutComponentView.mm */, B39BCD892E2A06D700FC90B8 /* RNMPRokt.h */, diff --git a/ios/RNMParticle/RNMPRokt.mm b/ios/RNMParticle/RNMPRokt.mm index fe9f297..aa78901 100644 --- a/ios/RNMParticle/RNMPRokt.mm +++ b/ios/RNMParticle/RNMPRokt.mm @@ -1,20 +1,5 @@ #import "RNMPRokt.h" -// SDK 9.0: ObjC headers moved to mParticle_Apple_SDK_ObjC module -#if defined(__has_include) && __has_include() - #import - #import -#elif defined(__has_include) && __has_include() - #import - #import -#else - #import - #import -#endif -#if __has_include() - #import -#elif __has_include() - #import -#endif +#import "RNMPSDKImports.h" #import #import #import diff --git a/ios/RNMParticle/RNMPSDKImports.h b/ios/RNMParticle/RNMPSDKImports.h new file mode 100644 index 0000000..48cc4bc --- /dev/null +++ b/ios/RNMParticle/RNMPSDKImports.h @@ -0,0 +1,28 @@ +// Private: the mParticle and RoktContracts headers, for every way an app can supply the SDKs. +// - CocoaPods (static library or framework): , +// - a manually embedded xcframework: , +// - flat public headers on the search path, as in a Swift Package Manager build: "mParticle.h" +// - last resort, module import: works in .m files only, because this pod builds .mm files +// without -fcxx-modules. +#if __has_include() + #import + #import +#elif __has_include() + #import + #import +#elif __has_include("mParticle.h") + #import "mParticle.h" + #import "MPRokt.h" +#else + @import mParticle_Apple_SDK_ObjC; +#endif + +#if __has_include() + #import +#elif __has_include() + #import +#elif __has_include("RoktContracts-Swift.h") + #import "RoktContracts-Swift.h" +#else + @import RoktContracts; +#endif diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index 01529f1..b4d71fd 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -1,14 +1,6 @@ #import "RNMParticle.h" #import -// SDK 9.0: ObjC headers moved to mParticle_Apple_SDK_ObjC module -#if defined(__has_include) && __has_include() - #import -#elif defined(__has_include) && __has_include() - #import -#else - #import -#endif -#import +#import "RNMPSDKImports.h" #import #if DEBUG #import diff --git a/ios/RNMParticle/RoktEventManager.mm b/ios/RNMParticle/RoktEventManager.mm index e8a5d92..f8fc373 100644 --- a/ios/RNMParticle/RoktEventManager.mm +++ b/ios/RNMParticle/RoktEventManager.mm @@ -1,11 +1,5 @@ #import "RoktEventManager.h" -// Not `@import RoktContracts` -- this file is Objective-C++ and the pod builds without -// -fcxx-modules, so the module import fails. Matches how RNMPRokt.mm imports the same types. -#if __has_include() - #import -#elif __has_include() - #import -#endif +#import "RNMPSDKImports.h" #import static os_log_t _rokt_events_os_log(void) { diff --git a/ios/RNMParticle/RoktLayoutManager.m b/ios/RNMParticle/RoktLayoutManager.m index c0ec142..873feca 100644 --- a/ios/RNMParticle/RoktLayoutManager.m +++ b/ios/RNMParticle/RoktLayoutManager.m @@ -1,11 +1,6 @@ #import #import -#if defined(__has_include) && __has_include() - #import -#else - #import -#endif -@import RoktContracts; +#import "RNMPSDKImports.h" #import #import "RoktPlaceholderRegistry.h" diff --git a/ios/RNMParticle/RoktNativeLayoutComponentView.h b/ios/RNMParticle/RoktNativeLayoutComponentView.h index 81a8258..e19adb8 100644 --- a/ios/RNMParticle/RoktNativeLayoutComponentView.h +++ b/ios/RNMParticle/RoktNativeLayoutComponentView.h @@ -2,16 +2,7 @@ #import #import #import -#if defined(__has_include) && __has_include() - #import -#else - #import -#endif -#if __has_include() - #import -#elif __has_include() - #import -#endif +#import "RNMPSDKImports.h" #ifndef RoktNativeLayoutComponentView_h #define RoktNativeLayoutComponentView_h From d99156194d473903085b1b1ce50f6c14edb4f55c Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:45:06 -0400 Subject: [PATCH 3/8] feat(ios): add opt-in Swift Package Manager mode for the mParticle SDKs (#423) Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/pull-request.yml | 29 +++++- MIGRATING.md | 16 ++++ README.md | 44 ++++++++- ios/mparticle_spm.rb | 93 +++++++++++++++++++ react-native-mparticle.podspec | 44 ++++++++- .../RCTConvertCommerceMappingTests.m | 10 +- .../RNMPRoktPlaceholderTests.m | 2 +- sample/ios/Podfile | 34 +++++-- scripts/ios/check-single-copy.sh | 29 ++++++ 9 files changed, 280 insertions(+), 21 deletions(-) create mode 100644 ios/mparticle_spm.rb create mode 100755 scripts/ios/check-single-copy.sh diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 31cdf43..2597c96 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -114,9 +114,20 @@ jobs: run: ./gradlew assembleDebug ios-sample-app: - # Keep this display name in sync with branch protection required checks ("iOS Sample App"). - name: iOS Sample App + # The CocoaPods leg keeps the display name the "main" ruleset requires ("iOS Sample App"). + name: ${{ matrix.check_name }} runs-on: macos-15 + strategy: + fail-fast: false + matrix: + include: + - ios_deps: cocoapods + check_name: iOS Sample App + - 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' }} steps: - name: Checkout uses: actions/checkout@v7 @@ -178,6 +189,20 @@ jobs: -only-testing:MParticleSampleTests/RNMParticleDuplicateSDKTests \ | bundle exec xcpretty -k + - name: Archive iOS sample app and check for a single copy of each SDK + working-directory: sample/ios + run: | + set -o pipefail && xcodebuild -workspace MParticleSample.xcworkspace \ + -configuration Release \ + -scheme MParticleSample \ + -destination 'generic/platform=iOS' \ + -derivedDataPath ios/build \ + -archivePath ios/build/MParticleSample.xcarchive \ + CODE_SIGNING_ALLOWED=NO \ + archive \ + | bundle exec xcpretty -k + ../../scripts/ios/check-single-copy.sh ios/build/MParticleSample.xcarchive/Products/Applications/MParticleSample.app + pr-notify: if: > github.event_name == 'pull_request' && diff --git a/MIGRATING.md b/MIGRATING.md index 6b31444..bc3ab56 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -2,6 +2,22 @@ This document provides migration guidance for changes in `react-native-mparticle`. +## Moving an iOS app to Swift Package Manager mode + +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. + +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`. + +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`. + ## Migrating embedded placements to placeholder names `MParticle.Rokt.selectPlacements` can find each embedded `RoktLayoutView` by its diff --git a/README.md b/README.md index 9a07862..3917fda 100644 --- a/README.md +++ b/README.md @@ -356,9 +356,49 @@ npm start and build your workspace from xCode. -### Swift Package Manager +### Swift Package Manager (opt-in) -Take the mParticle SDK and its kits from one dependency manager only. If the core comes from CocoaPods (through this package) and a kit such as `mParticle-Rokt` is added to the app with Swift Package Manager, the app contains two copies of the SDK. It builds and archives without warnings, then crashes at runtime. Debug builds detect this and show a red box: `[mParticle] The mParticle SDK is loaded more than once`. To fix it, remove the mParticle and Rokt Swift packages from the app and add the kits with CocoaPods, as shown above. +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: + + ```ruby + $RNMParticleUseSPM = true + require_relative '../node_modules/react-native-mparticle/ios/mparticle_spm' + ``` + +2. Remove the mParticle kit pods (for example `pod 'mParticle-Rokt'`) and the mParticle entries of the `pre_install` dynamic-framework hook shown above. + +3. At the end of `post_install`, after `react_native_post_install(...)`, list your kits: + + ```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. + +4. Run `pod install`, then build as usual. + +**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. + +**Linkage.** This package's pod is always built as a static framework in this mode, so it works with each CocoaPods linkage: + +| Podfile linkage | Supported | +| --------------------------------------- | --------- | +| Static libraries (React Native default) | ✓ | +| `use_frameworks! :linkage => :static` | ✓ | +| `use_frameworks! :linkage => :dynamic` | ✓ | + +**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. ## Android (Manual Setup) diff --git a/ios/mparticle_spm.rb b/ios/mparticle_spm.rb new file mode 100644 index 0000000..6523936 --- /dev/null +++ b/ios/mparticle_spm.rb @@ -0,0 +1,93 @@ +# Opt-in Swift Package Manager mode for the mParticle SDKs (see README › Swift Package Manager). +# +# Podfile usage (bare React Native): +# +# $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 +# +# 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. +require 'xcodeproj' + +module MParticleSPM + # Keep these URLs byte-identical to the ones the mParticle kits use, so SwiftPM unifies them into one + # 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 = '9.6.1'.freeze + CONFLICTING_PODS = /\A(mParticle-.*|Rokt-Widget|RoktContracts|RoktUXHelper|DcuiSchema)\z/.freeze + + 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." + end + + # Only iOS apps that use this pod: other apps in the same Podfile must not get the SDKs. + def self.application_targets(installer) + aggregates = installer.aggregate_targets.select do |aggregate| + aggregate.platform.name == :ios && aggregate.pod_targets.any? { |pod| pod.pod_name == 'react-native-mparticle' } + end + aggregates.flat_map do |aggregate| + aggregate.user_targets + .select { |t| t.product_type == 'com.apple.product-type.application' } + .map { |t| [aggregate.user_project, t] } + end.uniq { |project, target| [project.path.to_s, target.uuid] } + end + + def self.add_package(project, target, url:, product:, version:) + ref = project.root_object.package_references.find do |r| + r.is_a?(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference) && r.repositoryURL == url + end + unless ref + ref = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference) + ref.repositoryURL = url + project.root_object.package_references << ref + end + requirement = { 'kind' => 'exactVersion', 'version' => version } + changed = ref.requirement != requirement + ref.requirement = requirement + + return changed if target.package_product_dependencies.any? { |d| d.product_name == product } + + dep = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) + dep.package = ref + dep.product_name = product + target.package_product_dependencies << dep + build_file = project.new(Xcodeproj::Project::Object::PBXBuildFile) + build_file.product_ref = dep + 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) + packages = [{ url: MParticleSPM::CORE_URL, product: MParticleSPM::CORE_PRODUCT, version: core_version }] + kits + 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 + end + touched[project.path.to_s] = project + end + touched.each_value(&:save) +end diff --git a/react-native-mparticle.podspec b/react-native-mparticle.podspec index 8e6d6c4..86ff8ba 100644 --- a/react-native-mparticle.podspec +++ b/react-native-mparticle.podspec @@ -18,12 +18,52 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/mParticle/react-native-mparticle.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" + # 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 + + if use_spm + # The app target links mParticle and RoktContracts as Swift packages. This pod only compiles + # against their headers and never links them, so it is a static framework even under + # `use_frameworks! :linkage => :dynamic`. + s.static_framework = true + s.platforms = { :ios => ios_platform } # the Rokt kit Swift package is iOS-only + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '"$(DERIVED_FILE_DIR)/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"' + } + # Xcode writes a module map holding the absolute checkout path of each package, for build and + # archive alike, under OBJROOT. Linking that directory into this target's derived sources means no + # DerivedData or -clonedSourcePackagesDirPath layout is assumed. Declaring RoktContracts-Swift.h + # as an input orders this phase, and so this target's compile, after the RoktContracts package. + maps = '${OBJROOT}/GeneratedModuleMaps-${PLATFORM_NAME}' + s.script_phase = { + :name => '[mParticle] Locate Swift Package headers', + :execution_position => :before_compile, + :input_files => ["#{maps}/mParticle_Apple_SDK_ObjC.modulemap", "#{maps}/RoktContracts-Swift.h"], + :output_files => ['${DERIVED_FILE_DIR}/mParticleSPMInclude'], + :script => <<~'SH' + set -eu + 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." + exit 1 + fi + mkdir -p "${DERIVED_FILE_DIR}" + ln -sfn "$INC" "${DERIVED_FILE_DIR}/mParticleSPMInclude" + SH + } + end + if respond_to?(:install_modules_dependencies, true) install_modules_dependencies(s) else s.dependency "React-Core" end - s.dependency 'mParticle-Apple-SDK-ObjC', '>= 9.2.2', '< 10.0' - s.dependency 'RoktContracts', '~> 2.0' + unless use_spm + s.dependency 'mParticle-Apple-SDK-ObjC', '>= 9.2.2', '< 10.0' + s.dependency 'RoktContracts', '~> 2.0' + end end diff --git a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m index accab77..a2be6b2 100644 --- a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m +++ b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m @@ -2,14 +2,8 @@ #import #import "../../../ios/RNMParticle/RNMParticle.h" -// Match RNMParticle.mm / pod umbrella so tests compile against the same SDK the library uses. -#if defined(__has_include) && __has_include() -#import -#elif defined(__has_include) && __has_include() -#import -#else -#import -#endif +// The same SDK headers the library compiles against, in CocoaPods and Swift Package Manager mode. +#import "../../../ios/RNMParticle/RNMPSDKImports.h" // Implemented on `RCTConvert` in `RNMParticle.mm` (react-native-mparticle pod). // Parameter types mirror the implementations there. diff --git a/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m index fce176d..008dfda 100644 --- a/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m +++ b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m @@ -2,7 +2,7 @@ #import #import #import -@import RoktContracts; +#import "../../../ios/RNMParticle/RNMPSDKImports.h" #import "../../../ios/RNMParticle/RNMPRokt.h" #import "../../../ios/RNMParticle/RoktPlaceholderRegistry.h" diff --git a/sample/ios/Podfile b/sample/ios/Podfile index 809630e..23804f8 100644 --- a/sample/ios/Podfile +++ b/sample/ios/Podfile @@ -9,11 +9,21 @@ require Pod::Executable.execute_command('node', ['-p', platform :ios, '15.6' prepare_react_native_project! -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) +# 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 + +unless $RNMParticleUseSPM + 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 @@ -38,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' + pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0' unless $RNMParticleUseSPM target 'MParticleSampleTests' do inherit! :complete @@ -53,5 +63,17 @@ target 'MParticleSample' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + if $RNMParticleUseSPM + mparticle_spm_post_install(installer, kits: [ROKT_KIT]) + # 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)"' + installer.aggregate_targets.select { |t| t.name.end_with?('-MParticleSampleTests') }.each do |t| + t.xcconfigs.each do |build_config, xcconfig| + xcconfig.attributes['HEADER_SEARCH_PATHS'] = "#{xcconfig.attributes['HEADER_SEARCH_PATHS']} #{spm_headers}" + xcconfig.save_as(t.xcconfig_path(build_config)) + end + end + end end end diff --git a/scripts/ios/check-single-copy.sh b/scripts/ios/check-single-copy.sh new file mode 100755 index 0000000..ec2c0ee --- /dev/null +++ b/scripts/ios/check-single-copy.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Counts the Mach-O images in an .app that define each class. Expects exactly 1 each, so exits non-zero +# when an SDK is linked twice (for example from both CocoaPods and Swift Package Manager). +# Usage: scripts/ios/check-single-copy.sh path/to/App.app (use an archived or Release .app; Debug builds keep app code in App.debug.dylib, which is not scanned) +set -u +APP="${1}" +bins=("${APP}/$(basename "${APP}" .app)") +while IFS= read -r fw; do + bins+=("${fw}/$(basename "${fw}" .framework)") +done < <(find "${APP}/Frameworks" -maxdepth 1 -name '*.framework' 2>/dev/null || true) + +defines_class() { + local binary="${1}" cls="${2}" symbols objc + symbols="$(nm -U "${binary}" 2>/dev/null || true)" + objc="$(otool -oV "${binary}" 2>/dev/null || true)" + grep -qE "_OBJC_CLASS_\\\$_${cls}\$" <<<"${symbols}" || grep -qE "^ +name +0x[0-9a-f]+ ${cls}\$" <<<"${objc}" +} + +status=0 +for cls in MParticle RoktEmbeddedView MPKitRokt RNMPRokt RoktPlaceholderRegistry RoktNativeLayoutComponentView; do + hits=() + for b in "${bins[@]}"; do + [[ -f ${b} ]] || continue + if defines_class "${b}" "${cls}"; then hits+=("$(basename "${b}")"); fi + done + echo "${cls}: ${#hits[@]} [${hits[*]-}]" + [[ ${#hits[@]} -eq 1 ]] || status=1 +done +exit "${status}" From 4487463a416e86621b68f87dce06ea08cacb1f27 Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:49:37 -0400 Subject: [PATCH 4/8] feat(plugin): add iosDependencyManager 'spm' to the Expo config plugin (#424) 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 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; }, From 4f7d2f8d6e31e64b7e91effce43fd3ad1b507004 Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:18:37 -0400 Subject: [PATCH 5/8] refactor(ios): move RoktContracts-typed code into a Swift layer (#425) Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/pull-request.yml | 3 + ios/RNMParticle.xcodeproj/project.pbxproj | 2 + ios/RNMParticle/RNMPRokt.mm | 62 ++---- ios/RNMParticle/RNMPRoktSwift.h | 30 +++ ios/RNMParticle/RNMPSDKImports.h | 5 + ios/RNMParticle/RoktEventManager.mm | 168 ++-------------- .../RoktNativeLayoutComponentView.h | 4 +- .../RoktNativeLayoutComponentView.mm | 8 +- .../Swift/RNMPRoktConfigFactory.swift | 34 ++++ .../Swift/RNMPRoktEventMapper.swift | 84 ++++++++ ios/RNMParticle/Swift/RNMPRoktViews.swift | 16 ++ react-native-mparticle.podspec | 14 +- .../MParticleSample.xcodeproj/project.pbxproj | 12 ++ .../RNMPRoktConfigFactoryTests.m | 63 ++++++ .../RNMPRoktEventMapperTests.m | 184 ++++++++++++++++++ .../MParticleSampleTests/RNMPRoktSwiftTests.m | 37 ++++ ...oktNativeLayoutComponentViewSizingTests.mm | 19 ++ 17 files changed, 536 insertions(+), 209 deletions(-) create mode 100644 ios/RNMParticle/RNMPRoktSwift.h create mode 100644 ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift create mode 100644 ios/RNMParticle/Swift/RNMPRoktEventMapper.swift create mode 100644 ios/RNMParticle/Swift/RNMPRoktViews.swift create mode 100644 sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m create mode 100644 sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m create mode 100644 sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2597c96..b54d41b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -187,6 +187,9 @@ jobs: -only-testing:MParticleSampleTests/RCTConvertCommerceMappingTests \ -only-testing:MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests \ -only-testing:MParticleSampleTests/RNMParticleDuplicateSDKTests \ + -only-testing:MParticleSampleTests/RNMPRoktEventMapperTests \ + -only-testing:MParticleSampleTests/RNMPRoktConfigFactoryTests \ + -only-testing:MParticleSampleTests/RNMPRoktSwiftTests \ | bundle exec xcpretty -k - name: Archive iOS sample app and check for a single copy of each SDK diff --git a/ios/RNMParticle.xcodeproj/project.pbxproj b/ios/RNMParticle.xcodeproj/project.pbxproj index 6e7405e..286fcd8 100644 --- a/ios/RNMParticle.xcodeproj/project.pbxproj +++ b/ios/RNMParticle.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ C4A1D2E32F6A000100ABCDEF /* RoktPlaceholderRegistry.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RoktPlaceholderRegistry.h; sourceTree = ""; }; C4A1D2E42F6A000100ABCDEF /* RoktPlaceholderRegistry.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RoktPlaceholderRegistry.m; sourceTree = ""; }; C4A1D2E62F6A000100ABCDEF /* RNMPSDKImports.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNMPSDKImports.h; sourceTree = ""; }; + C4A1D2E72F6A000100ABCDEF /* RNMPRoktSwift.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNMPRoktSwift.h; sourceTree = ""; }; B39BCD942E30562400FC90B8 /* RoktNativeLayoutComponentView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RoktNativeLayoutComponentView.h; sourceTree = ""; }; B39BCD952E30562400FC90B8 /* RoktNativeLayoutComponentView.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RoktNativeLayoutComponentView.mm; sourceTree = ""; }; DBDF24D71E007EB1000F3D73 /* libRNMParticle.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNMParticle.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -81,6 +82,7 @@ C4A1D2E32F6A000100ABCDEF /* RoktPlaceholderRegistry.h */, C4A1D2E42F6A000100ABCDEF /* RoktPlaceholderRegistry.m */, C4A1D2E62F6A000100ABCDEF /* RNMPSDKImports.h */, + C4A1D2E72F6A000100ABCDEF /* RNMPRoktSwift.h */, B39BCD942E30562400FC90B8 /* RoktNativeLayoutComponentView.h */, B39BCD952E30562400FC90B8 /* RoktNativeLayoutComponentView.mm */, B39BCD892E2A06D700FC90B8 /* RNMPRokt.h */, diff --git a/ios/RNMParticle/RNMPRokt.mm b/ios/RNMParticle/RNMPRokt.mm index aa78901..64af67d 100644 --- a/ios/RNMParticle/RNMPRokt.mm +++ b/ios/RNMParticle/RNMPRokt.mm @@ -1,5 +1,6 @@ #import "RNMPRokt.h" #import "RNMPSDKImports.h" +#import "RNMPRoktSwift.h" #import #import #import @@ -119,14 +120,14 @@ - (void)selectPlacements:(NSString *)identifer NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes]; NSDictionary *roktConfigDict = safeExtractRoktConfigDict(roktConfig); - RoktConfig *config = [self buildRoktConfigFromDict:roktConfigDict]; + RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfigDict]; #else // Old Architecture Implementation — selectPlacements RCT_EXPORT_METHOD(selectPlacements:(NSString *) identifer attributes:(NSDictionary *)attributes placeholders:(NSDictionary * _Nullable)placeholders roktConfig:(NSDictionary * _Nullable)roktConfig fontFilesMap:(NSDictionary * _Nullable)fontFilesMap) { _rokt_log(@"[mParticle-Rokt] Old Architecture Implementation"); NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes]; - RoktConfig *config = [self buildRoktConfigFromDict:roktConfig]; + RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfig]; #endif _rokt_log(@"[mParticle-Rokt] selectPlacements called with identifier: %@, attributes count: %lu", identifer, (unsigned long)finalAttributes.count); @@ -171,7 +172,7 @@ - (void)selectPlacements:(NSString *)identifer // Replaced by a newer call with the same identifier, or cancelled by close(): the SDK // is never called, so report the failure the way the SDK reports a call it rejects. _rokt_log(@"[mParticle-Rokt] pending selectPlacements dropped for: %@", identifer); - [weakSelf.eventManager onRoktEvents:[[RoktPlacementFailure alloc] initWithIdentifier:nil] viewName:identifer]; + [weakSelf.eventManager onRoktEvents:[RNMPRoktEventMapper placementFailure] viewName:identifer]; }]; }); } @@ -185,14 +186,14 @@ - (void)selectShoppableAds:(NSString *)identifier _rokt_log(@"[mParticle-Rokt] selectShoppableAds New Architecture"); NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes]; NSDictionary *roktConfigDict = safeExtractRoktConfigDict(roktConfig); - RoktConfig *config = [self buildRoktConfigFromDict:roktConfigDict]; + RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfigDict]; #else // Old Architecture Implementation — selectShoppableAds RCT_EXPORT_METHOD(selectShoppableAds:(NSString *)identifier attributes:(NSDictionary *)attributes roktConfig:(NSDictionary * _Nullable)roktConfig) { _rokt_log(@"[mParticle-Rokt] selectShoppableAds Old Architecture"); NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes]; - RoktConfig *config = [self buildRoktConfigFromDict:roktConfig]; + RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfig]; #endif _rokt_log(@"[mParticle-Rokt] selectShoppableAds called with identifier: %@, attributes count: %lu", identifier, (unsigned long)finalAttributes.count); @@ -310,45 +311,6 @@ - (void)getSessionIdWithResolve:(RCTPromiseResolveBlock)resolve return finalAttributes; } -- (RoktConfig *)buildRoktConfigFromDict:(NSDictionary *)configMap { - _rokt_log(@"[mParticle-Rokt] buildRoktConfigFromDict: configMap %@", configMap == nil ? @"nil" : [NSString stringWithFormat:@"non-nil (%lu keys)", (unsigned long)configMap.count]); - if (configMap == nil || configMap.count == 0) { - _rokt_log(@"[mParticle-Rokt] buildRoktConfigFromDict: returning nil"); - return nil; - } - - RoktConfigBuilder *builder = [[RoktConfigBuilder alloc] init]; - BOOL isConfigEmpty = YES; - - NSString *colorModeString = configMap[@"colorMode"]; - if (colorModeString && [colorModeString isKindOfClass:[NSString class]]) { - isConfigEmpty = NO; - if ([colorModeString isEqualToString:@"dark"]) { - [builder colorMode:RoktColorModeDark]; - } else if ([colorModeString isEqualToString:@"light"]) { - [builder colorMode:RoktColorModeLight]; - } else { - [builder colorMode:RoktColorModeSystem]; - } - } - - NSDictionary *cacheConfigMap = configMap[@"cacheConfig"]; - if (cacheConfigMap && [cacheConfigMap isKindOfClass:[NSDictionary class]]) { - isConfigEmpty = NO; - NSNumber *cacheDuration = cacheConfigMap[@"cacheDurationInSeconds"]; - if (!cacheDuration) { - cacheDuration = @0; - } - NSDictionary *cacheAttributes = cacheConfigMap[@"cacheAttributes"]; - RoktCacheConfig *cacheConfig = [[RoktCacheConfig alloc] initWithCacheDuration:[cacheDuration longLongValue] - cacheAttributes:cacheAttributes ?: @{}]; - [builder cacheConfig:cacheConfig]; - } - - _rokt_log(@"[mParticle-Rokt] buildRoktConfigFromDict: returning %@", isConfigEmpty ? @"nil" : @"config"); - return isConfigEmpty ? nil : [builder build]; -} - // Main thread only — RCTViewRegistry and RoktPlaceholderRegistry read the mounted view hierarchy. // A positive numeric value is a legacy findNodeHandle react tag. Zero is the name-lookup // sentinel used by the JS wrapper; unresolved tags also fall back to placeholderName. @@ -359,15 +321,15 @@ - (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders for(id key in placeholders){ id reactTag = [placeholders objectForKey:key]; - RoktEmbeddedView *embeddedView = nil; + UIView *embeddedView = nil; if ([reactTag isKindOfClass:[NSNumber class]] && [reactTag integerValue] > 0) { embeddedView = [self embeddedViewForReactTag:reactTag]; } if (embeddedView == nil && [key isKindOfClass:[NSString class]]) { UIView *view = [RoktPlaceholderRegistry viewForName:key]; - // nil fails isKindOfClass:, covering both "not mounted" and "wrong class". - if ([view isKindOfClass:[RoktEmbeddedView class]]) { - embeddedView = (RoktEmbeddedView *)view; + // nil is not an embedded view, covering both "not mounted" and "wrong class". + if ([RNMPRoktViews isEmbeddedView:view]) { + embeddedView = view; } } if (embeddedView == nil) { @@ -396,7 +358,7 @@ - (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders return pending; } -- (nullable RoktEmbeddedView *)embeddedViewForReactTag:(NSNumber *)reactTag +- (nullable UIView *)embeddedViewForReactTag:(NSNumber *)reactTag { UIView *view = [_viewRegistry_DEPRECATED viewForReactTag:reactTag]; #ifdef RCT_NEW_ARCH_ENABLED @@ -404,7 +366,7 @@ - (nullable RoktEmbeddedView *)embeddedViewForReactTag:(NSNumber *)reactTag ? ((RoktNativeLayoutComponentView *)view).roktEmbeddedView : nil; #else - return [view isKindOfClass:[RoktEmbeddedView class]] ? (RoktEmbeddedView *)view : nil; + return [RNMPRoktViews isEmbeddedView:view] ? view : nil; #endif // RCT_NEW_ARCH_ENABLED } diff --git a/ios/RNMParticle/RNMPRoktSwift.h b/ios/RNMParticle/RNMPRoktSwift.h new file mode 100644 index 0000000..c1464ab --- /dev/null +++ b/ios/RNMParticle/RNMPRoktSwift.h @@ -0,0 +1,30 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// Implemented in Swift (Swift/*.swift). Declared by hand instead of importing the generated +// -Swift.h header: under Swift Package Manager the Swift code is a separate target, and +// Objective-C++ files cannot reach another target's generated header without C++ modules. +// Only Foundation and UIKit types appear here, so Objective-C++ never needs the RoktContracts +// headers. Keep each declaration in sync with its @objc name; RNMPRoktSwiftTests checks them. + +@interface RNMPRoktEventMapper : NSObject +// Keys: "payload" (the RoktEvents body), optional "callback" (a RoktCallback value), and +// optional "height" and "placement" (LayoutHeightChanges). ++ (NSDictionary *)mapEvent:(id)event viewName:(nullable NSString *)viewName; +// A RoktPlacementFailure with no identifier. ++ (id)placementFailure; +@end + +@interface RNMPRoktConfigFactory : NSObject +// A RoktConfig, or nil when the dictionary has no usable keys. ++ (nullable id)configFromDictionary:(nullable NSDictionary *)dictionary; +@end + +@interface RNMPRoktViews : NSObject +// A RoktEmbeddedView. ++ (UIView *)makeEmbeddedViewWithFrame:(CGRect)frame; ++ (BOOL)isEmbeddedView:(nullable UIView *)view; +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RNMParticle/RNMPSDKImports.h b/ios/RNMParticle/RNMPSDKImports.h index 48cc4bc..d32130a 100644 --- a/ios/RNMParticle/RNMPSDKImports.h +++ b/ios/RNMParticle/RNMPSDKImports.h @@ -17,6 +17,10 @@ @import mParticle_Apple_SDK_ObjC; #endif +// RoktContracts for Objective-C (.m) files only. Objective-C++ files use its types through the +// Swift layer (RNMPRoktSwift.h), because a Swift Package Manager Objective-C++ target cannot +// import them; the SDK headers above only forward-declare RoktEmbeddedView, RoktConfig and RoktEvent. +#ifndef __cplusplus #if __has_include() #import #elif __has_include() @@ -26,3 +30,4 @@ #else @import RoktContracts; #endif +#endif // __cplusplus diff --git a/ios/RNMParticle/RoktEventManager.mm b/ios/RNMParticle/RoktEventManager.mm index f8fc373..d30d299 100644 --- a/ios/RNMParticle/RoktEventManager.mm +++ b/ios/RNMParticle/RoktEventManager.mm @@ -1,5 +1,5 @@ #import "RoktEventManager.h" -#import "RNMPSDKImports.h" +#import "RNMPRoktSwift.h" #import static os_log_t _rokt_events_os_log(void) { @@ -80,157 +80,21 @@ - (void)onRoktCallbackReceived:(NSString*)eventValue - (void)onRoktEvents:(RoktEvent * _Nonnull)event viewName:(NSString * _Nullable)viewName { - NSString *eventClass = event ? NSStringFromClass([event class]) : @"nil"; - _rokt_events_log(@"[mParticle-Rokt] RoktEventManager onRoktEvents: %@ viewName: %@", eventClass, viewName ?: @"(nil)"); - if (hasListeners) { - NSString *placementId; - NSString *eventName = @""; - NSString *status; - NSString *url; - NSString *cartItemId; - NSString *catalogItemId; - NSString *currency; - NSString *itemDescription; - NSString *linkedProductId; - NSString *providerData; - NSDecimalNumber *quantity; - NSDecimalNumber *totalPrice; - NSDecimalNumber *unitPrice; - NSString *error; - NSString *paymentProvider; - - if ([event isKindOfClass:[RoktShowLoadingIndicator class]]) { - eventName = @"ShowLoadingIndicator"; - [self onRoktCallbackReceived:@"onShouldShowLoadingIndicator"]; - } else if ([event isKindOfClass:[RoktHideLoadingIndicator class]]) { - eventName = @"HideLoadingIndicator"; - [self onRoktCallbackReceived:@"onShouldHideLoadingIndicator"]; - } else if ([event isKindOfClass:[RoktPlacementInteractive class]]) { - placementId = ((RoktPlacementInteractive *)event).identifier; - eventName = @"PlacementInteractive"; - } else if ([event isKindOfClass:[RoktPlacementReady class]]) { - placementId = ((RoktPlacementReady *)event).identifier; - eventName = @"PlacementReady"; - [self onRoktCallbackReceived:@"onLoad"]; - } else if ([event isKindOfClass:[RoktOfferEngagement class]]) { - placementId = ((RoktOfferEngagement *)event).identifier; - eventName = @"OfferEngagement"; - } else if ([event isKindOfClass:[RoktPositiveEngagement class]]) { - placementId = ((RoktPositiveEngagement *)event).identifier; - eventName = @"PositiveEngagement"; - } else if ([event isKindOfClass:[RoktPlacementClosed class]]) { - placementId = ((RoktPlacementClosed *)event).identifier; - eventName = @"PlacementClosed"; - [self onRoktCallbackReceived:@"onUnLoad"]; - } else if ([event isKindOfClass:[RoktPlacementCompleted class]]) { - placementId = ((RoktPlacementCompleted *)event).identifier; - eventName = @"PlacementCompleted"; - } else if ([event isKindOfClass:[RoktPlacementFailure class]]) { - placementId = ((RoktPlacementFailure *)event).identifier; - eventName = @"PlacementFailure"; - } else if ([event isKindOfClass:[RoktFirstPositiveEngagement class]]) { - placementId = ((RoktFirstPositiveEngagement *)event).identifier; - eventName = @"FirstPositiveEngagement"; - } else if ([event isKindOfClass:[RoktInitComplete class]]) { - eventName = @"InitComplete"; - status = ((RoktInitComplete *)event).success ? @"true" : @"false"; - } else if ([event isKindOfClass:[RoktOpenUrl class]]) { - eventName = @"OpenUrl"; - placementId = ((RoktOpenUrl *)event).identifier; - url = ((RoktOpenUrl *)event).url; - } else if ([event isKindOfClass:[RoktEmbeddedSizeChanged class]]) { - RoktEmbeddedSizeChanged *sizeEvent = (RoktEmbeddedSizeChanged *)event; - placementId = sizeEvent.identifier; - eventName = @"EmbeddedSizeChanged"; - [self onWidgetHeightChanges:sizeEvent.updatedHeight placement:sizeEvent.identifier]; - } else if ([event isKindOfClass:[RoktCartItemInstantPurchase class]]) { - RoktCartItemInstantPurchase *cartEvent = (RoktCartItemInstantPurchase *)event; - eventName = @"CartItemInstantPurchase"; - placementId = cartEvent.identifier; - cartItemId = cartEvent.cartItemId; - catalogItemId = cartEvent.catalogItemId; - currency = cartEvent.currency; - providerData = cartEvent.providerData; - linkedProductId = cartEvent.linkedProductId; - itemDescription = cartEvent.description; - quantity = cartEvent.quantity; - totalPrice = cartEvent.totalPrice; - unitPrice = cartEvent.unitPrice; - } else if ([event isKindOfClass:[RoktCartItemInstantPurchaseInitiated class]]) { - RoktCartItemInstantPurchaseInitiated *initiatedEvent = (RoktCartItemInstantPurchaseInitiated *)event; - eventName = @"CartItemInstantPurchaseInitiated"; - placementId = initiatedEvent.identifier; - catalogItemId = initiatedEvent.catalogItemId; - cartItemId = initiatedEvent.cartItemId; - } else if ([event isKindOfClass:[RoktCartItemInstantPurchaseFailure class]]) { - RoktCartItemInstantPurchaseFailure *failureEvent = (RoktCartItemInstantPurchaseFailure *)event; - eventName = @"CartItemInstantPurchaseFailure"; - placementId = failureEvent.identifier; - catalogItemId = failureEvent.catalogItemId; - cartItemId = failureEvent.cartItemId; - error = failureEvent.error; - } else if ([event isKindOfClass:[RoktInstantPurchaseDismissal class]]) { - RoktInstantPurchaseDismissal *dismissalEvent = (RoktInstantPurchaseDismissal *)event; - eventName = @"InstantPurchaseDismissal"; - placementId = dismissalEvent.identifier; - } else if ([event isKindOfClass:[RoktCartItemDevicePay class]]) { - RoktCartItemDevicePay *devicePayEvent = (RoktCartItemDevicePay *)event; - eventName = @"CartItemDevicePay"; - placementId = devicePayEvent.identifier; - catalogItemId = devicePayEvent.catalogItemId; - cartItemId = devicePayEvent.cartItemId; - paymentProvider = devicePayEvent.paymentProvider; - } - - NSMutableDictionary *payload = [@{@"event": eventName} mutableCopy]; - if (viewName != nil) { - [payload setObject:viewName forKey:@"viewName"]; - } - if (placementId != nil) { - [payload setObject:placementId forKey:@"placementId"]; - } - if (status != nil) { - [payload setObject:status forKey:@"status"]; - } - if (url != nil) { - [payload setObject:url forKey:@"url"]; - } - if (cartItemId != nil) { - [payload setObject:cartItemId forKey:@"cartItemId"]; - } - if (catalogItemId != nil) { - [payload setObject:catalogItemId forKey:@"catalogItemId"]; - } - if (currency != nil) { - [payload setObject:currency forKey:@"currency"]; - } - if (itemDescription != nil) { - [payload setObject:itemDescription forKey:@"description"]; - } - if (linkedProductId != nil) { - [payload setObject:linkedProductId forKey:@"linkedProductId"]; - } - if (providerData != nil) { - [payload setObject:providerData forKey:@"providerData"]; - } - if (quantity != nil) { - [payload setObject:quantity forKey:@"quantity"]; - } - if (totalPrice != nil) { - [payload setObject:totalPrice forKey:@"totalPrice"]; - } - if (unitPrice != nil) { - [payload setObject:unitPrice forKey:@"unitPrice"]; - } - if (error != nil) { - [payload setObject:error forKey:@"error"]; - } - if (paymentProvider != nil) { - [payload setObject:paymentProvider forKey:@"paymentProvider"]; - } - - [self sendEventWithName:@"RoktEvents" body:payload]; - } + // RoktEvent is only forward-declared here, so message it as an NSObject. + NSString *eventClass = event ? NSStringFromClass([(NSObject *)event class]) : @"nil"; + _rokt_events_log(@"[mParticle-Rokt] RoktEventManager onRoktEvents: %@ viewName: %@", eventClass, viewName ?: @"(nil)"); + if (!hasListeners) { + return; + } + // The mapping lives in Swift (RNMPRoktEventMapper), which can see the Rokt event types. + NSDictionary *mapped = [RNMPRoktEventMapper mapEvent:event viewName:viewName]; + if (mapped[@"callback"] != nil) { + [self onRoktCallbackReceived:mapped[@"callback"]]; + } + if (mapped[@"height"] != nil) { + [self onWidgetHeightChanges:[mapped[@"height"] doubleValue] placement:mapped[@"placement"]]; + } + [self sendEventWithName:@"RoktEvents" body:mapped[@"payload"]]; } #ifdef RCT_NEW_ARCH_ENABLED diff --git a/ios/RNMParticle/RoktNativeLayoutComponentView.h b/ios/RNMParticle/RoktNativeLayoutComponentView.h index e19adb8..fbc9421 100644 --- a/ios/RNMParticle/RoktNativeLayoutComponentView.h +++ b/ios/RNMParticle/RoktNativeLayoutComponentView.h @@ -2,7 +2,6 @@ #import #import #import -#import "RNMPSDKImports.h" #ifndef RoktNativeLayoutComponentView_h #define RoktNativeLayoutComponentView_h @@ -10,7 +9,8 @@ NS_ASSUME_NONNULL_BEGIN @interface RoktNativeLayoutComponentView : RCTViewComponentView -@property (nonatomic, readonly) RoktEmbeddedView *roktEmbeddedView; +// The RoktEmbeddedView the SDK renders into (typed UIView so Objective-C++ needs no Rokt headers). +@property (nonatomic, readonly) UIView *roktEmbeddedView; @end NS_ASSUME_NONNULL_END diff --git a/ios/RNMParticle/RoktNativeLayoutComponentView.mm b/ios/RNMParticle/RoktNativeLayoutComponentView.mm index c79a32d..aef0b2d 100644 --- a/ios/RNMParticle/RoktNativeLayoutComponentView.mm +++ b/ios/RNMParticle/RoktNativeLayoutComponentView.mm @@ -2,6 +2,7 @@ #import #import "RoktNativeLayoutComponentView.h" #import "RoktPlaceholderRegistry.h" +#import "RNMPRoktSwift.h" #import #import @@ -11,7 +12,7 @@ using namespace facebook::react; @interface RoktNativeLayoutComponentView () -@property (nonatomic, nullable) RoktEmbeddedView *roktEmbeddedView; +@property (nonatomic, nullable) UIView *roktEmbeddedView; @property (nonatomic, nullable) NSString *placeholderName; @end @@ -27,7 +28,10 @@ + (ComponentDescriptorProvider)componentDescriptorProvider - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { - _roktEmbeddedView = [[RoktEmbeddedView alloc] initWithFrame:self.bounds]; + // The superclass leaves plain ViewProps here, and debug builds of React Native assert on + // the first updateProps if a subclass keeps them, so start from this component's defaults. + _props = RoktNativeLayoutShadowNode::defaultSharedProps(); + _roktEmbeddedView = [RNMPRoktViews makeEmbeddedViewWithFrame:self.bounds]; // Width only: the Rokt SDK owns the height (updateEmbeddedSize sets it before JS // resizes this view), so flexible height would re-apply that delta once React Native // caught up, doubling the embedded view and centring the layout below blank space. diff --git a/ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift b/ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift new file mode 100644 index 0000000..f1f86ba --- /dev/null +++ b/ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift @@ -0,0 +1,34 @@ +import Foundation +import RoktContracts + +/// Builds the Rokt SDK config from the `roktConfig` object JavaScript passes. Returns nil when the +/// object has no usable keys, which the SDK treats as "no config". +@objc(RNMPRoktConfigFactory) +public final class RNMPRoktConfigFactory: NSObject { + @objc(configFromDictionary:) + public static func config(from map: [String: Any]?) -> RoktConfig? { + guard let map, !map.isEmpty else { return nil } + + let builder = RoktConfig.Builder() + var isEmpty = true + + if let colorMode = map["colorMode"] as? String { + isEmpty = false + switch colorMode { + case "dark": builder.colorMode(.dark) + case "light": builder.colorMode(.light) + default: builder.colorMode(.system) + } + } + + if let cache = map["cacheConfig"] as? [String: Any] { + isEmpty = false + // Whole seconds, as the Objective-C version read it (longLongValue). + let seconds = (cache["cacheDurationInSeconds"] as? NSNumber)?.int64Value ?? 0 + let attributes = cache["cacheAttributes"] as? [String: String] ?? [:] + builder.cacheConfig(RoktConfig.CacheConfig(cacheDuration: TimeInterval(seconds), cacheAttributes: attributes)) + } + + return isEmpty ? nil : builder.build() + } +} diff --git a/ios/RNMParticle/Swift/RNMPRoktEventMapper.swift b/ios/RNMParticle/Swift/RNMPRoktEventMapper.swift new file mode 100644 index 0000000..677e397 --- /dev/null +++ b/ios/RNMParticle/Swift/RNMPRoktEventMapper.swift @@ -0,0 +1,84 @@ +import Foundation +import RoktContracts + +/// Maps a Rokt SDK event to the `RoktEvents` payload sent to JavaScript, plus the side effects +/// RoktEventManager performs for it. Pure mapping only; React stays in RoktEventManager.mm. +@objc(RNMPRoktEventMapper) +public final class RNMPRoktEventMapper: NSObject { + /// Keys: "payload" (the JS body), optional "callback" (RoktCallback value), and optional + /// "height" + "placement" (LayoutHeightChanges). + @objc(mapEvent:viewName:) + public static func map(_ event: RoktEvent, viewName: String?) -> [String: Any] { + var name = "" + var fields: [String: Any?] = [:] + var callback: String? + var height: CGFloat? + var placement: String? + + switch event { + case is RoktEvent.ShowLoadingIndicator: + name = "ShowLoadingIndicator"; callback = "onShouldShowLoadingIndicator" + case is RoktEvent.HideLoadingIndicator: + name = "HideLoadingIndicator"; callback = "onShouldHideLoadingIndicator" + case let e as RoktEvent.PlacementInteractive: + name = "PlacementInteractive"; fields["placementId"] = e.identifier + case let e as RoktEvent.PlacementReady: + name = "PlacementReady"; fields["placementId"] = e.identifier; callback = "onLoad" + case let e as RoktEvent.OfferEngagement: + name = "OfferEngagement"; fields["placementId"] = e.identifier + case let e as RoktEvent.PositiveEngagement: + name = "PositiveEngagement"; fields["placementId"] = e.identifier + case let e as RoktEvent.PlacementClosed: + name = "PlacementClosed"; fields["placementId"] = e.identifier; callback = "onUnLoad" + case let e as RoktEvent.PlacementCompleted: + name = "PlacementCompleted"; fields["placementId"] = e.identifier + case let e as RoktEvent.PlacementFailure: + name = "PlacementFailure"; fields["placementId"] = e.identifier + case let e as RoktEvent.FirstPositiveEngagement: + name = "FirstPositiveEngagement"; fields["placementId"] = e.identifier + case let e as RoktEvent.InitComplete: + name = "InitComplete"; fields["status"] = e.success ? "true" : "false" + case let e as RoktEvent.OpenUrl: + name = "OpenUrl"; fields["placementId"] = e.identifier; fields["url"] = e.url + case let e as RoktEvent.EmbeddedSizeChanged: + name = "EmbeddedSizeChanged"; fields["placementId"] = e.identifier + height = e.updatedHeight; placement = e.identifier + case let e as RoktEvent.CartItemInstantPurchase: + name = "CartItemInstantPurchase" + fields = ["placementId": e.identifier, "cartItemId": e.cartItemId, "catalogItemId": e.catalogItemId, + "currency": e.currency, "providerData": e.providerData, "linkedProductId": e.linkedProductId, + "description": e.description, "quantity": e.quantity, "totalPrice": e.totalPrice, + "unitPrice": e.unitPrice] + case let e as RoktEvent.CartItemInstantPurchaseInitiated: + name = "CartItemInstantPurchaseInitiated" + fields = ["placementId": e.identifier, "catalogItemId": e.catalogItemId, "cartItemId": e.cartItemId] + case let e as RoktEvent.CartItemInstantPurchaseFailure: + name = "CartItemInstantPurchaseFailure" + fields = ["placementId": e.identifier, "catalogItemId": e.catalogItemId, "cartItemId": e.cartItemId, + "error": e.error] + case let e as RoktEvent.InstantPurchaseDismissal: + name = "InstantPurchaseDismissal"; fields["placementId"] = e.identifier + case let e as RoktEvent.CartItemDevicePay: + name = "CartItemDevicePay" + fields = ["placementId": e.identifier, "catalogItemId": e.catalogItemId, "cartItemId": e.cartItemId, + "paymentProvider": e.paymentProvider] + default: + break + } + + var payload: [String: Any] = fields.compactMapValues { $0 } + payload["event"] = name + if let viewName { payload["viewName"] = viewName } + + var result: [String: Any] = ["payload": payload] + if let callback { result["callback"] = callback } + if let height, let placement { result["height"] = Double(height); result["placement"] = placement } + return result + } + + /// The event the SDK sends for a call it rejects; also sent when a pending call is dropped. + @objc(placementFailure) + public static func placementFailure() -> RoktEvent { + RoktEvent.PlacementFailure(identifier: nil) + } +} diff --git a/ios/RNMParticle/Swift/RNMPRoktViews.swift b/ios/RNMParticle/Swift/RNMPRoktViews.swift new file mode 100644 index 0000000..43f044e --- /dev/null +++ b/ios/RNMParticle/Swift/RNMPRoktViews.swift @@ -0,0 +1,16 @@ +import RoktContracts +import UIKit + +/// The embedded view the Rokt SDK renders into, for Objective-C++ code that cannot see its type. +@objc(RNMPRoktViews) +public final class RNMPRoktViews: NSObject { + @objc(makeEmbeddedViewWithFrame:) + public static func makeEmbeddedView(frame: CGRect) -> UIView { + RoktEmbeddedView(frame: frame) + } + + @objc(isEmbeddedView:) + public static func isEmbeddedView(_ view: UIView?) -> Bool { + view is RoktEmbeddedView + } +} diff --git a/react-native-mparticle.podspec b/react-native-mparticle.podspec index 86ff8ba..90bfaa3 100644 --- a/react-native-mparticle.podspec +++ b/react-native-mparticle.podspec @@ -17,6 +17,12 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/mParticle/react-native-mparticle.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" + # The Rokt-typed code is Swift (ios/RNMParticle/Swift); Objective-C++ reaches it through the + # hand-written RNMPRoktSwift.h. Private headers keep C++ and React headers out of the module's + # umbrella header, which the Swift code's module would otherwise fail to build. + s.swift_version = '5.0' + 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 @@ -29,9 +35,9 @@ Pod::Spec.new do |s| # `use_frameworks! :linkage => :dynamic`. s.static_framework = true s.platforms = { :ios => ios_platform } # the Rokt kit Swift package is iOS-only - s.pod_target_xcconfig = { - 'HEADER_SEARCH_PATHS' => '"$(DERIVED_FILE_DIR)/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"' - } + xcconfig['HEADER_SEARCH_PATHS'] = '"$(DERIVED_FILE_DIR)/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"' + # Where Xcode puts the Swift packages' .swiftmodule files, for build and archive alike. + xcconfig['SWIFT_INCLUDE_PATHS'] = '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)"' # Xcode writes a module map holding the absolute checkout path of each package, for build and # archive alike, under OBJROOT. Linking that directory into this target's derived sources means no # DerivedData or -clonedSourcePackagesDirPath layout is assumed. Declaring RoktContracts-Swift.h @@ -56,6 +62,8 @@ Pod::Spec.new do |s| } end + s.pod_target_xcconfig = xcconfig + if respond_to?(:install_modules_dependencies, true) install_modules_dependencies(s) else diff --git a/sample/ios/MParticleSample.xcodeproj/project.pbxproj b/sample/ios/MParticleSample.xcodeproj/project.pbxproj index 127cf13..daeba0c 100644 --- a/sample/ios/MParticleSample.xcodeproj/project.pbxproj +++ b/sample/ios/MParticleSample.xcodeproj/project.pbxproj @@ -18,7 +18,10 @@ B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */; }; B7C10E952E50AA1100000006 /* RoktNativeLayoutComponentViewSizingTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */; }; B7C10E972E50AA1100000008 /* RNMParticleDuplicateSDKTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */; }; + B7C10E992E50AA110000000A /* RNMPRoktEventMapperTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E982E50AA1100000009 /* RNMPRoktEventMapperTests.m */; }; B7C10E992F6B000000000009 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E982F6B000000000008 /* SceneDelegate.swift */; }; + B7C10E9B2E50AA110000000C /* RNMPRoktConfigFactoryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E9A2E50AA110000000B /* RNMPRoktConfigFactoryTests.m */; }; + B7C10E9D2E50AA110000000E /* RNMPRoktSwiftTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E9C2E50AA110000000D /* RNMPRoktSwiftTests.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -51,7 +54,10 @@ B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktPlaceholderTests.m; sourceTree = ""; }; B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RoktNativeLayoutComponentViewSizingTests.mm; sourceTree = ""; }; B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMParticleDuplicateSDKTests.m; sourceTree = ""; }; + B7C10E982E50AA1100000009 /* RNMPRoktEventMapperTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktEventMapperTests.m; sourceTree = ""; }; B7C10E982F6B000000000008 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = MParticleSample/SceneDelegate.swift; sourceTree = ""; }; + B7C10E9A2E50AA110000000B /* RNMPRoktConfigFactoryTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktConfigFactoryTests.m; sourceTree = ""; }; + B7C10E9C2E50AA110000000D /* RNMPRoktSwiftTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktSwiftTests.m; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -83,6 +89,9 @@ B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */, B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */, B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */, + B7C10E982E50AA1100000009 /* RNMPRoktEventMapperTests.m */, + B7C10E9A2E50AA110000000B /* RNMPRoktConfigFactoryTests.m */, + B7C10E9C2E50AA110000000D /* RNMPRoktSwiftTests.m */, 00E356F01AD99517003FC87E /* Supporting Files */, ); path = MParticleSampleTests; @@ -403,6 +412,9 @@ B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */, B7C10E952E50AA1100000006 /* RoktNativeLayoutComponentViewSizingTests.mm in Sources */, B7C10E972E50AA1100000008 /* RNMParticleDuplicateSDKTests.m in Sources */, + B7C10E992E50AA110000000A /* RNMPRoktEventMapperTests.m in Sources */, + B7C10E9B2E50AA110000000C /* RNMPRoktConfigFactoryTests.m in Sources */, + B7C10E9D2E50AA110000000E /* RNMPRoktSwiftTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m b/sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m new file mode 100644 index 0000000..a63f62c --- /dev/null +++ b/sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m @@ -0,0 +1,63 @@ +#import +#import "../../../ios/RNMParticle/RNMPSDKImports.h" +#import "../../../ios/RNMParticle/RNMPRoktSwift.h" + +/** + * Pins how the `roktConfig` object from JavaScript becomes a RoktConfig, including which inputs + * mean "no config" (nil). Written against the original Objective-C (`-[RNMPRokt + * buildRoktConfigFromDict:]`), which RNMPRoktConfigFactory replaced unchanged. + */ +@interface RNMPRoktConfigFactoryTests : XCTestCase +@end + +@implementation RNMPRoktConfigFactoryTests + +- (RoktConfig *)configFrom:(NSDictionary *)dict +{ + return [RNMPRoktConfigFactory configFromDictionary:dict]; +} + +- (void)testNoUsableKeysMeansNoConfig +{ + XCTAssertNil([self configFrom:nil]); + XCTAssertNil([self configFrom:@{}]); + XCTAssertNil([self configFrom:@{@"unknown" : @"x"}]); + XCTAssertNil([self configFrom:@{@"colorMode" : @1}]); + XCTAssertNil([self configFrom:@{@"cacheConfig" : @"not a dictionary"}]); +} + +- (void)testColorMode +{ + XCTAssertEqual([self configFrom:@{@"colorMode" : @"dark"}].colorMode, RoktColorModeDark); + XCTAssertEqual([self configFrom:@{@"colorMode" : @"light"}].colorMode, RoktColorModeLight); + XCTAssertEqual([self configFrom:@{@"colorMode" : @"system"}].colorMode, RoktColorModeSystem); + XCTAssertEqual([self configFrom:@{@"colorMode" : @"sepia"}].colorMode, RoktColorModeSystem); +} + +- (void)testCacheConfig +{ + RoktConfig *config = [self configFrom:@{@"cacheConfig" : @{@"cacheDurationInSeconds" : @120, @"cacheAttributes" : @{@"email" : @"a@b.c"}}}]; + XCTAssertEqual(config.cacheConfig.cacheDuration, 120); + XCTAssertEqualObjects(config.cacheConfig.cacheAttributes, (@{@"email" : @"a@b.c"})); +} + +- (void)testCacheConfigDefaultsAndTruncation +{ + RoktConfig *empty = [self configFrom:@{@"cacheConfig" : @{}}]; + XCTAssertNotNil(empty); + // A missing duration is sent as 0, which the SDK raises to its maximum. + XCTAssertEqual(empty.cacheConfig.cacheDuration, RoktCacheConfig.maxCacheDuration); + XCTAssertEqualObjects(empty.cacheConfig.cacheAttributes, @{}); + + // Whole seconds only: the duration was always read with longLongValue. + XCTAssertEqual([self configFrom:@{@"cacheConfig" : @{@"cacheDurationInSeconds" : @90.7}}].cacheConfig.cacheDuration, 90); +} + +- (void)testColorModeAndCacheTogether +{ + RoktConfig *config = [self configFrom:@{@"colorMode" : @"dark", @"cacheConfig" : @{@"cacheDurationInSeconds" : @60}}]; + XCTAssertEqual(config.colorMode, RoktColorModeDark); + XCTAssertEqual(config.cacheConfig.cacheDuration, 60); +} + +@end diff --git a/sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m b/sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m new file mode 100644 index 0000000..4773284 --- /dev/null +++ b/sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m @@ -0,0 +1,184 @@ +#import +#import +#import "../../../ios/RNMParticle/RNMPSDKImports.h" +#import "../../../ios/RNMParticle/RoktEventManager.h" + +/** + * Pins what RoktEventManager sends to JavaScript for every Rokt event: the `RoktEvents` payload + * and the `RoktCallback` / `LayoutHeightChanges` events some of them also trigger, in order. + * Written against the original Objective-C mapping, which RNMPRoktEventMapper replaced unchanged. + */ +static NSMutableArray *RNMPSentEvents; + +// Records instead of sending. Adds no ivars, so an existing manager can be switched to it. +@interface RNMPRecordingEventManager : RoktEventManager +@end + +@implementation RNMPRecordingEventManager +- (void)sendEventWithName:(NSString *)name body:(id)body +{ + [RNMPSentEvents addObject:@[ name, body ]]; +} +@end + +@interface RNMPRoktEventMapperTests : XCTestCase +@end + +@implementation RNMPRoktEventMapperTests { + RoktEventManager *_manager; + Class _originalClass; +} + +- (void)setUp +{ + [super setUp]; + RNMPSentEvents = [NSMutableArray array]; + _manager = [RoktEventManager allocWithZone:nil]; + _originalClass = object_getClass(_manager); + object_setClass(_manager, [RNMPRecordingEventManager class]); + [_manager startObserving]; +} + +- (void)tearDown +{ + [_manager stopObserving]; + object_setClass(_manager, _originalClass); + [super tearDown]; +} + +- (NSArray *)send:(RoktEvent *)event +{ + [RNMPSentEvents removeAllObjects]; + [_manager onRoktEvents:event viewName:@"checkout"]; + return [RNMPSentEvents copy]; +} + +- (NSArray *)roktEvents:(NSDictionary *)fields +{ + NSMutableDictionary *payload = [fields mutableCopy]; + payload[@"viewName"] = @"checkout"; + return @[ @"RoktEvents", payload ]; +} + +- (NSArray *)callback:(NSString *)value +{ + return @[ @"RoktCallback", @{@"callbackValue" : value} ]; +} + +- (void)testLoadingIndicatorsAlsoSendCallbacks +{ + XCTAssertEqualObjects([self send:[[RoktShowLoadingIndicator alloc] init]], + (@[ [self callback:@"onShouldShowLoadingIndicator"], [self roktEvents:@{@"event" : @"ShowLoadingIndicator"}] ])); + XCTAssertEqualObjects([self send:[[RoktHideLoadingIndicator alloc] init]], + (@[ [self callback:@"onShouldHideLoadingIndicator"], [self roktEvents:@{@"event" : @"HideLoadingIndicator"}] ])); +} + +- (void)testPlacementLifecycleEvents +{ + XCTAssertEqualObjects([self send:[[RoktPlacementReady alloc] initWithIdentifier:@"p1"]], + (@[ [self callback:@"onLoad"], [self roktEvents:@{@"event" : @"PlacementReady", @"placementId" : @"p1"}] ])); + XCTAssertEqualObjects([self send:[[RoktPlacementClosed alloc] initWithIdentifier:@"p1"]], + (@[ [self callback:@"onUnLoad"], [self roktEvents:@{@"event" : @"PlacementClosed", @"placementId" : @"p1"}] ])); + + NSDictionary *plain = @{ + @"PlacementInteractive" : [[RoktPlacementInteractive alloc] initWithIdentifier:@"p1"], + @"OfferEngagement" : [[RoktOfferEngagement alloc] initWithIdentifier:@"p1"], + @"PositiveEngagement" : [[RoktPositiveEngagement alloc] initWithIdentifier:@"p1"], + @"PlacementCompleted" : [[RoktPlacementCompleted alloc] initWithIdentifier:@"p1"], + @"PlacementFailure" : [[RoktPlacementFailure alloc] initWithIdentifier:@"p1"], + @"FirstPositiveEngagement" : [[RoktFirstPositiveEngagement alloc] initWithIdentifier:@"p1" setFulfillmentAttributes:nil], + @"InstantPurchaseDismissal" : [[RoktInstantPurchaseDismissal alloc] initWithIdentifier:@"p1"], + }; + [plain enumerateKeysAndObjectsUsingBlock:^(NSString *name, RoktEvent *event, BOOL *stop) { + XCTAssertEqualObjects([self send:event], (@[ [self roktEvents:@{@"event" : name, @"placementId" : @"p1"}] ]), @"%@", name); + }]; +} + +- (void)testMissingIdentifierIsOmitted +{ + XCTAssertEqualObjects([self send:[[RoktPlacementFailure alloc] initWithIdentifier:nil]], + (@[ [self roktEvents:@{@"event" : @"PlacementFailure"}] ])); +} + +- (void)testInitCompleteAndOpenUrl +{ + XCTAssertEqualObjects([self send:[[RoktInitComplete alloc] initWithSuccess:YES]], + (@[ [self roktEvents:@{@"event" : @"InitComplete", @"status" : @"true"}] ])); + XCTAssertEqualObjects([self send:[[RoktInitComplete alloc] initWithSuccess:NO]], + (@[ [self roktEvents:@{@"event" : @"InitComplete", @"status" : @"false"}] ])); + XCTAssertEqualObjects([self send:[[RoktOpenUrl alloc] initWithIdentifier:@"p1" url:@"https://example.com"]], + (@[ [self roktEvents:@{@"event" : @"OpenUrl", @"placementId" : @"p1", @"url" : @"https://example.com"}] ])); +} + +- (void)testEmbeddedSizeChangedAlsoSendsTheHeight +{ + XCTAssertEqualObjects([self send:[[RoktEmbeddedSizeChanged alloc] initWithIdentifier:@"p1" updatedHeight:412.5]], + (@[ @[ @"LayoutHeightChanges", @{@"height" : @412.5, @"selectedPlacement" : @"p1"} ], + [self roktEvents:@{@"event" : @"EmbeddedSizeChanged", @"placementId" : @"p1"}] ])); +} + +- (void)testInstantPurchaseEvents +{ + RoktEvent *purchase = [[RoktCartItemInstantPurchase alloc] initWithIdentifier:@"p1" + name:@"ignored" + cartItemId:@"cart" + catalogItemId:@"catalog" + currency:@"USD" + description:@"A thing" + linkedProductId:@"linked" + providerData:@"data" + quantity:[NSDecimalNumber decimalNumberWithString:@"2"] + totalPrice:[NSDecimalNumber decimalNumberWithString:@"19.98"] + unitPrice:[NSDecimalNumber decimalNumberWithString:@"9.99"]]; + XCTAssertEqualObjects([self send:purchase], (@[ [self roktEvents:@{ + @"event" : @"CartItemInstantPurchase", + @"placementId" : @"p1", + @"cartItemId" : @"cart", + @"catalogItemId" : @"catalog", + @"currency" : @"USD", + @"description" : @"A thing", + @"linkedProductId" : @"linked", + @"providerData" : @"data", + @"quantity" : [NSDecimalNumber decimalNumberWithString:@"2"], + @"totalPrice" : [NSDecimalNumber decimalNumberWithString:@"19.98"], + @"unitPrice" : [NSDecimalNumber decimalNumberWithString:@"9.99"], + }] ])); + + RoktEvent *sparse = [[RoktCartItemInstantPurchase alloc] initWithIdentifier:@"p1" name:nil cartItemId:@"cart" catalogItemId:@"catalog" currency:@"USD" description:@"A thing" linkedProductId:nil providerData:@"data" quantity:nil totalPrice:nil unitPrice:nil]; + XCTAssertEqualObjects([self send:sparse], (@[ [self roktEvents:@{ + @"event" : @"CartItemInstantPurchase", + @"placementId" : @"p1", + @"cartItemId" : @"cart", + @"catalogItemId" : @"catalog", + @"currency" : @"USD", + @"description" : @"A thing", + @"providerData" : @"data", + }] ])); + + XCTAssertEqualObjects([self send:[[RoktCartItemInstantPurchaseInitiated alloc] initWithIdentifier:@"p1" catalogItemId:@"catalog" cartItemId:@"cart"]], + (@[ [self roktEvents:@{@"event" : @"CartItemInstantPurchaseInitiated", @"placementId" : @"p1", @"catalogItemId" : @"catalog", @"cartItemId" : @"cart"}] ])); + XCTAssertEqualObjects([self send:[[RoktCartItemInstantPurchaseFailure alloc] initWithIdentifier:@"p1" catalogItemId:@"catalog" cartItemId:@"cart" error:@"declined"]], + (@[ [self roktEvents:@{@"event" : @"CartItemInstantPurchaseFailure", @"placementId" : @"p1", @"catalogItemId" : @"catalog", @"cartItemId" : @"cart", @"error" : @"declined"}] ])); + XCTAssertEqualObjects([self send:[[RoktCartItemDevicePay alloc] initWithIdentifier:@"p1" catalogItemId:@"catalog" cartItemId:@"cart" paymentProvider:@"applePay"]], + (@[ [self roktEvents:@{@"event" : @"CartItemDevicePay", @"placementId" : @"p1", @"catalogItemId" : @"catalog", @"cartItemId" : @"cart", @"paymentProvider" : @"applePay"}] ])); +} + +- (void)testUnknownEventSendsAnEmptyName +{ + XCTAssertEqualObjects([self send:[[RoktEvent alloc] init]], (@[ [self roktEvents:@{@"event" : @""}] ])); +} + +- (void)testNilViewNameIsOmitted +{ + [RNMPSentEvents removeAllObjects]; + [_manager onRoktEvents:[[RoktPlacementReady alloc] initWithIdentifier:@"p1"] viewName:nil]; + XCTAssertEqualObjects(RNMPSentEvents.lastObject, (@[ @"RoktEvents", @{@"event" : @"PlacementReady", @"placementId" : @"p1"} ])); +} + +- (void)testNothingIsSentWithoutListeners +{ + [_manager stopObserving]; + XCTAssertEqualObjects([self send:[[RoktPlacementReady alloc] initWithIdentifier:@"p1"]], @[]); +} + +@end diff --git a/sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m b/sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m new file mode 100644 index 0000000..d64394e --- /dev/null +++ b/sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m @@ -0,0 +1,37 @@ +#import +#import "../../../ios/RNMParticle/RNMPSDKImports.h" +#import "../../../ios/RNMParticle/RNMPRoktSwift.h" + +/** + * RNMPRoktSwift.h declares the Swift classes by hand, so nothing checks it against the Swift code + * at compile time. A mismatch only fails at runtime with "unrecognized selector", so check every + * declared selector here. + */ +@interface RNMPRoktSwiftTests : XCTestCase +@end + +@implementation RNMPRoktSwiftTests + +- (void)testDeclaredSelectorsExist +{ + XCTAssertTrue([RNMPRoktEventMapper respondsToSelector:@selector(mapEvent:viewName:)]); + XCTAssertTrue([RNMPRoktEventMapper respondsToSelector:@selector(placementFailure)]); + XCTAssertTrue([RNMPRoktConfigFactory respondsToSelector:@selector(configFromDictionary:)]); + XCTAssertTrue([RNMPRoktViews respondsToSelector:@selector(makeEmbeddedViewWithFrame:)]); + XCTAssertTrue([RNMPRoktViews respondsToSelector:@selector(isEmbeddedView:)]); +} + +- (void)testDeclaredTypes +{ + XCTAssertTrue([[RNMPRoktEventMapper placementFailure] isKindOfClass:[RoktPlacementFailure class]]); + XCTAssertNil([(RoktPlacementFailure *)[RNMPRoktEventMapper placementFailure] identifier]); + + UIView *view = [RNMPRoktViews makeEmbeddedViewWithFrame:CGRectMake(0, 0, 10, 20)]; + XCTAssertTrue([view isKindOfClass:[RoktEmbeddedView class]]); + XCTAssertTrue(CGRectEqualToRect(view.frame, CGRectMake(0, 0, 10, 20))); + XCTAssertTrue([RNMPRoktViews isEmbeddedView:view]); + XCTAssertFalse([RNMPRoktViews isEmbeddedView:[[UIView alloc] init]]); + XCTAssertFalse([RNMPRoktViews isEmbeddedView:nil]); +} + +@end diff --git a/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm b/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm index 902769e..9c7c9da 100644 --- a/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm +++ b/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm @@ -1,4 +1,5 @@ #import +#import #import "../../../ios/RNMParticle/RoktNativeLayoutComponentView.h" // Deliberately not wrapped in #ifdef RCT_NEW_ARCH_ENABLED: the header is, so a build @@ -72,3 +73,21 @@ - (void)testEmbeddedWidthFollowsComponentWidth } @end + +/** + * Debug builds of React Native crash on the first updateProps if the view kept the superclass's + * plain ViewProps; release builds only log it, so check the props type rather than the crash. + */ +@interface RoktNativeLayoutComponentViewPropsTests : XCTestCase +@end + +@implementation RoktNativeLayoutComponentViewPropsTests + +- (void)testStartsWithRoktNativeLayoutDefaultProps +{ + RoktNativeLayoutComponentView *componentView = [[RoktNativeLayoutComponentView alloc] initWithFrame:CGRectZero]; + + XCTAssertTrue(std::dynamic_pointer_cast([componentView props]) != nullptr); +} + +@end From 1af3ff551499e5373865a5e3c0af450162a8c166 Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:52:32 -0400 Subject: [PATCH 6/8] feat(ios): add experimental Package.swift for React Native SPM mode (#426) Co-authored-by: Claude Opus 5.5 (1M context) --- Package.swift | 73 ++++++++++++++++++++++ README.md | 11 ++++ js/__tests__/package-swift-sources.test.ts | 24 +++++++ package.json | 3 +- 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 Package.swift create mode 100644 js/__tests__/package-swift-sources.test.ts diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e1a12a2 --- /dev/null +++ b/Package.swift @@ -0,0 +1,73 @@ +// swift-tools-version: 6.0 +// Experimental: for React Native's Swift Package Manager mode (React Native 0.87 or later), which +// React Native itself marks as not for production. CocoaPods apps do not use this file. +// +// The ReactNative and React-GeneratedCode packages are the local packages React Native's autolinker +// generates for each app, at these paths relative to this package, the same layout other React +// Native libraries use. Revisit them when React Native publishes a remote Swift package. + +import PackageDescription + +let package = Package( + name: "ReactNativeMparticle", + // React Native's autolinker requires iOS 15; the mParticle SDK declares the same. + platforms: [.iOS(.v15)], + products: [ + .library(name: "ReactNativeMparticle", targets: ["ReactNativeMparticle"]), + ], + dependencies: [ + .package(name: "ReactNative", path: "../../../../xcframeworks"), + .package(name: "React-GeneratedCode", path: "../../../ios"), + // Same URLs as the mParticle kits use, so SwiftPM unifies them into one package identity. + .package(url: "https://github.com/mParticle/mparticle-apple-sdk", "9.2.2"..<"10.0.0"), + .package(url: "https://github.com/ROKT/rokt-contracts-apple.git", "2.0.0"..<"3.0.0"), + ], + targets: [ + // A separate target: SwiftPM cannot mix Swift and Objective-C++ in one target. + .target( + name: "RNMParticleSwift", + dependencies: [.product(name: "RoktContracts", package: "rokt-contracts-apple")], + path: "ios/RNMParticle/Swift" + ), + .target( + name: "ReactNativeMparticle", + dependencies: [ + "RNMParticleSwift", + .product(name: "ReactHeaders", package: "ReactNative"), + .product(name: "ReactNativeHeaders", package: "ReactNative"), + .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"), + .product(name: "ReactAppHeaders", package: "React-GeneratedCode"), + .product(name: "mParticle-Apple-SDK", package: "mparticle-apple-sdk"), + .product(name: "RoktContracts", package: "rokt-contracts-apple"), + ], + path: ".", + sources: [ + "ios/RNMParticle/RNMPRokt.h", + "ios/RNMParticle/RNMPRokt.mm", + "ios/RNMParticle/RNMPRoktSwift.h", + "ios/RNMParticle/RNMPSDKImports.h", + "ios/RNMParticle/RNMParticle.h", + "ios/RNMParticle/RNMParticle.mm", + "ios/RNMParticle/RoktEventManager.h", + "ios/RNMParticle/RoktEventManager.mm", + "ios/RNMParticle/RoktLayoutManager.m", + "ios/RNMParticle/RoktNativeLayoutComponentView.h", + "ios/RNMParticle/RoktNativeLayoutComponentView.mm", + "ios/RNMParticle/RoktPlaceholderRegistry.h", + "ios/RNMParticle/RoktPlaceholderRegistry.m", + ], + publicHeadersPath: "ios", + // Without RCT_NEW_ARCH_ENABLED the TurboModule and Fabric code compiles out, and the app + // crashes looking up the RoktNativeLayout component. + cSettings: [.define("RCT_NEW_ARCH_ENABLED", to: "1"), .headerSearchPath("ios/RNMParticle")], + cxxSettings: [ + .define("RCT_NEW_ARCH_ENABLED", to: "1"), + .headerSearchPath("ios/RNMParticle"), + .define("DEBUG", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + ], + linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")] + ), + ], + cxxLanguageStandard: .cxx20 +) diff --git a/README.md b/README.md index a71d923..aec8be5 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,17 @@ Every mParticle and Rokt SDK must then come from Swift Package Manager, and none - 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. +### 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. + +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): + +- `https://github.com/mParticle/mparticle-apple-sdk`, product `mParticle-Apple-SDK` +- `https://github.com/mparticle-integrations/mp-apple-integration-rokt`, product `mParticle-Rokt` + +Use exactly these URLs, with no `.git` suffix on the first, so Swift Package Manager treats them as the same packages the kits depend on. Then start mParticle in your Swift `AppDelegate` with `import mParticle_Apple_SDK`, as in the CocoaPods setup. + ## Android (Manual Setup) 1. Copy your mParticle key and secret from [your workspace's dashboard](https://app.mparticle.com/setup/inputs/apps) and construct an `MParticleOptions` object. diff --git a/js/__tests__/package-swift-sources.test.ts b/js/__tests__/package-swift-sources.test.ts new file mode 100644 index 0000000..657a6b8 --- /dev/null +++ b/js/__tests__/package-swift-sources.test.ts @@ -0,0 +1,24 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * The podspec compiles every file under ios/ by glob, but Package.swift lists the Objective-C++ + * target's sources by hand, and CI does not build that manifest. Keep the two in step. + */ +const root = path.join(__dirname, '..', '..'); + +it('lists every ios/RNMParticle source in Package.swift', () => { + const manifest = fs.readFileSync(path.join(root, 'Package.swift'), 'utf-8'); + const listed = new Set( + [...manifest.matchAll(/"(ios\/RNMParticle\/[^"]+\.(?:h|m|mm))"/g)].map( + match => match[1] + ) + ); + const onDisk = fs + .readdirSync(path.join(root, 'ios', 'RNMParticle'), { recursive: true }) + .map(String) + .filter(file => /\.(h|m|mm)$/.test(file)) + .map(file => `ios/RNMParticle/${file}`); + + expect([...listed].sort()).toEqual(onDisk.sort()); +}); diff --git a/package.json b/package.json index 4256faa..f9b6fb3 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "lib", "react-native-mparticle.podspec", "app.plugin.js", - "plugin" + "plugin", + "Package.swift" ], "publishConfig": { "access": "public", From 5b07b80e0c386815550160d8ff5a337e17012093 Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:59:29 -0400 Subject: [PATCH 7/8] feat(rokt)!: accept only placeholder names in selectPlacements (#428) Co-authored-by: Claude Opus 5.5 (1M context) --- MIGRATING.md | 18 ++-- README.md | 4 +- .../mparticle/react/rokt/MPRoktModuleImpl.kt | 54 ++++++++---- .../react/rokt/RoktPlaceholderRegistry.kt | 2 +- .../com/mparticle/react/rokt/MPRoktModule.kt | 68 +-------------- .../com/mparticle/react/NativeMPRoktSpec.kt | 3 +- .../com/mparticle/react/rokt/MPRoktModule.kt | 33 +------ .../react/rokt/MPRoktModuleImplTest.kt | 5 +- ios/RNMParticle/RNMPRokt.mm | 63 ++++---------- ios/RNMParticle/RoktPlaceholderRegistry.h | 2 +- js/__tests__/rokt-placeholders.test.ts | 35 +++++--- js/codegenSpecs/rokt/NativeMPRokt.ts | 2 +- js/rokt/rokt.ts | 38 ++++----- .../RNMPRoktPlaceholderTests.m | 85 +++++++------------ 14 files changed, 149 insertions(+), 263 deletions(-) diff --git a/MIGRATING.md b/MIGRATING.md index bc3ab56..fe36472 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -20,11 +20,14 @@ To go back, undo these steps: remove `$RNMParticleUseSPM` and the helper call, r ## Migrating embedded placements to placeholder names -`MParticle.Rokt.selectPlacements` can find each embedded `RoktLayoutView` by its +`MParticle.Rokt.selectPlacements` finds each embedded `RoktLayoutView` by its `placeholderName`, so apps no longer need a ref, `findNodeHandle`, or to wait for -the view to mount before calling it. This is not a breaking change: the map of -placeholder names to React tags still works. We recommend moving to names the -next time you touch the integration. +the view to mount before calling it. + +**Breaking:** the map of placeholder names to React tags has been removed, and +`placeholders` must be an array of names. A map passed from plain JavaScript logs +an error, and the placement is requested without embedded views. Earlier 3.x +releases accept both forms, so you can switch to names before you upgrade. Before, tag-based: @@ -64,7 +67,7 @@ return ; To migrate, remove the ref, the `findNodeHandle` import and any `onLayout` handler or timer used to delay the call, then pass an array of placeholder names. Each name must match the `placeholderName` of a `RoktLayoutView`, the same -key the map uses today. +key the map used. ### Behavior changes to check @@ -72,15 +75,10 @@ key the map uses today. seconds.** The SDK waits for the view, then calls Rokt with the views it has. A misspelled or never-rendered name therefore arrives 2 seconds late, and is logged as `Cannot resolve placeholder`. -- **`null` in the map form is resolved by name.** `findNodeHandle` returns `null` - before the view mounts; that placeholder was skipped before and is now looked - up by its key. - **A waiting call can end in `PlacementFailure`.** If `close()` runs, or a newer call with the same identifier replaces it, before its placeholders mount, the waiting call emits `PlacementFailure` instead of being dropped silently. -The map form is planned for removal in a future major version. - ## Migrating from versions < 3.0.0 `3.0.0` moved iOS to the mParticle Apple SDK **9.x**. Later 3.x releases raised diff --git a/README.md b/README.md index aec8be5..653b603 100644 --- a/README.md +++ b/README.md @@ -785,8 +785,8 @@ useEffect(() => { return ; ``` -The earlier form, a map of `placeholderName` to `findNodeHandle(ref)`, is still -supported: `{ Location1: findNodeHandle(this.placeholder1.current) }`. +The earlier map of `placeholderName` to `findNodeHandle(ref)` is no longer +supported: see [MIGRATING](./MIGRATING.md#migrating-embedded-placements-to-placeholder-names). | Method | Notes | | ----------------------------------------------------- | ----------------------------------------------------- | diff --git a/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt b/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt index 1f65f1a..46b9d22 100644 --- a/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt +++ b/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt @@ -9,6 +9,7 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType import com.facebook.react.bridge.UiThreadUtil @@ -17,6 +18,7 @@ import com.facebook.react.modules.core.DeviceEventManagerModule import com.mparticle.MParticle import com.mparticle.WrapperSdk import com.mparticle.internal.Logger +import com.mparticle.kits.RoktEmbeddedView import com.mparticle.kits.rokt import com.rokt.roktsdk.CacheConfig import com.rokt.roktsdk.RoktConfig @@ -24,6 +26,7 @@ import com.rokt.roktsdk.RoktEvent import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import java.lang.ref.WeakReference import java.math.BigDecimal class MPRoktModuleImpl( @@ -74,15 +77,14 @@ class MPRoktModuleImpl( } /** - * Runs [select] on the UI thread once every placeholder named for name-based resolution has - * mounted, or after [PLACEHOLDER_MOUNT_TIMEOUT_MS]. A placeholder may not be mounted yet when - * selectPlacements arrives, e.g. when it is called from the same useEffect that rendered it: - * Fabric creates views on the next frame. Legacy react tags are never waited for. - * Must be called on the UI thread. + * Runs [select] on the UI thread once every named placeholder has mounted, or after + * [PLACEHOLDER_MOUNT_TIMEOUT_MS]. A placeholder may not be mounted yet when selectPlacements + * arrives, e.g. when it is called from the same useEffect that rendered it: Fabric creates + * views on the next frame. Must be called on the UI thread. */ fun whenPlaceholdersMounted( identifier: String, - placeholders: ReadableMap?, + placeholders: ReadableArray?, select: () -> Unit, ) { val pending = unmountedPlaceholderNames(placeholders) @@ -111,19 +113,37 @@ class MPRoktModuleImpl( sendEvent(reactContext, "RoktEvents", params) } - // Names passed for name-based resolution (a non-positive value) that have no mounted view yet. - internal fun unmountedPlaceholderNames(placeholders: ReadableMap?): List { - if (placeholders == null) return emptyList() - val pending = mutableListOf() - val iterator = placeholders.keySetIterator() - while (iterator.hasNextKey()) { - val key = iterator.nextKey() - val isReactTag = placeholders.getType(key) == ReadableType.Number && placeholders.getDouble(key) > 0 - if (!isReactTag && RoktPlaceholderRegistry.lookup(key) == null) { - pending += key + /** + * Resolves placeholder names to their mounted RoktEmbeddedView instances. + * Must be called on the UI thread — it resolves live views. + */ + fun resolvePlaceholders(placeholders: ReadableArray?): Map> { + val names = placeholderNames(placeholders) + if (names.size != (placeholders?.size() ?: 0)) { + Logger.warning("Ignoring placeholders that are not placeholderName strings") + } + val views = HashMap>() + for (name in names) { + val view = RoktPlaceholderRegistry.lookup(name) as? RoktEmbeddedView + if (view != null) { + views[name] = WeakReference(view) + } else { + Logger.warning("Cannot resolve placeholder for key: $name") } } - return pending + return views + } + + // Placeholder names that have no mounted view yet. + internal fun unmountedPlaceholderNames(placeholders: ReadableArray?): List = + placeholderNames(placeholders).filter { RoktPlaceholderRegistry.lookup(it) == null } + + // Only strings are placeholder names; getString would throw on any other entry. + private fun placeholderNames(placeholders: ReadableArray?): List { + if (placeholders == null) return emptyList() + return (0 until placeholders.size()).mapNotNull { index -> + if (placeholders.getType(index) == ReadableType.String) placeholders.getString(index) else null + } } fun setSessionId( diff --git a/android/src/main/java/com/mparticle/react/rokt/RoktPlaceholderRegistry.kt b/android/src/main/java/com/mparticle/react/rokt/RoktPlaceholderRegistry.kt index fc642fe..a0688dc 100644 --- a/android/src/main/java/com/mparticle/react/rokt/RoktPlaceholderRegistry.kt +++ b/android/src/main/java/com/mparticle/react/rokt/RoktPlaceholderRegistry.kt @@ -7,7 +7,7 @@ import java.lang.ref.WeakReference /** * Maps a `RoktLayoutView`'s `placeholderName` to its mounted native view, so - * `selectPlacements` can resolve placeholders by name instead of a `findNodeHandle` react tag. + * `selectPlacements` can resolve placeholders by name. * * UI thread only: views register from the view manager and are resolved inside * `runOnUiThread` / `addUIBlock`, so no locking. diff --git a/android/src/newarch/java/com/mparticle/react/rokt/MPRoktModule.kt b/android/src/newarch/java/com/mparticle/react/rokt/MPRoktModule.kt index 4ccc2bc..9dd4d3b 100644 --- a/android/src/newarch/java/com/mparticle/react/rokt/MPRoktModule.kt +++ b/android/src/newarch/java/com/mparticle/react/rokt/MPRoktModule.kt @@ -3,16 +3,13 @@ package com.mparticle.react.rokt import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.ReadableType import com.facebook.react.bridge.UiThreadUtil -import com.facebook.react.uimanager.UIManagerHelper import com.mparticle.MParticle import com.mparticle.internal.Logger -import com.mparticle.kits.RoktEmbeddedView import com.mparticle.kits.rokt import com.mparticle.react.NativeMPRoktSpec -import java.lang.ref.WeakReference class MPRoktModule( private val reactContext: ReactApplicationContext, @@ -25,7 +22,7 @@ class MPRoktModule( override fun selectPlacements( identifier: String, attributes: ReadableMap?, - placeholders: ReadableMap?, + placeholders: ReadableArray?, roktConfig: ReadableMap?, fontFilesMap: ReadableMap?, ) { @@ -43,14 +40,13 @@ class MPRoktModule( // Rokt SDK 6's selectPlacements clears prior placeholder content via removeAllViews(), // which detaches Compose views and must run on the main thread. Resolve the placeholder - // views and invoke the SDK together on the UI thread. (oldarch uses UIManager.addUIBlock; - // iOS uses the uiManager methodQueue — same intent.) + // views and invoke the SDK together on the UI thread, as oldarch's UIManager.addUIBlock does. UiThreadUtil.runOnUiThread { impl.whenPlaceholdersMounted(identifier, placeholders) { MParticle.getInstance()?.rokt?.selectPlacements( identifier = identifier, attributes = attributeMap, - embeddedViews = resolvePlaceholders(placeholders), + embeddedViews = impl.resolvePlaceholders(placeholders), fontTypefaces = null, // TODO config = config, ) @@ -93,60 +89,4 @@ class MPRoktModule( override fun getSessionId(promise: Promise) { impl.getSessionId(promise) } - - /** - * Resolve placeholders to their RoktEmbeddedView instances. A positive numeric value is a - * legacy `findNodeHandle` react tag. Zero is the name-lookup sentinel used by the JS wrapper; - * unresolved tags also fall back to placeholderName. - * Must be called on the UI thread — it resolves live views. - */ - private fun resolvePlaceholders(placeholders: ReadableMap?): Map> { - val placeholdersMap = HashMap>() - if (placeholders == null) { - return placeholdersMap - } - - val iterator = placeholders.keySetIterator() - while (iterator.hasNextKey()) { - val key = iterator.nextKey() - try { - val taggedView = - if ( - placeholders.getType(key) == ReadableType.Number && - placeholders.getDouble(key) > 0 - ) { - resolveReactTag(placeholders.getDouble(key).toInt()) - } else { - null - } - val view = taggedView ?: RoktPlaceholderRegistry.lookup(key) as? RoktEmbeddedView - - if (view != null) { - placeholdersMap[key] = WeakReference(view) - Logger.debug("Successfully found Widget for key: $key") - } else { - Logger.warning("Cannot resolve placeholder for key: $key") - } - } catch (e: Exception) { - Logger.warning("Error processing placeholder for key $key: ${e.message}") - } - } - - return placeholdersMap - } - - private fun resolveReactTag(reactTag: Int): RoktEmbeddedView? { - val uiManager = UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag) - if (uiManager == null) { - Logger.warning("UIManager not found for tag: $reactTag") - return null - } - // resolveView throws for a tag that is no longer mounted; the caller falls back to the name. - val view = runCatching { uiManager.resolveView(reactTag) }.getOrNull() - if (view !is RoktEmbeddedView) { - Logger.warning("View with tag $reactTag is not a Widget: ${view?.javaClass?.simpleName}") - return null - } - return view - } } diff --git a/android/src/oldarch/java/com/mparticle/react/NativeMPRoktSpec.kt b/android/src/oldarch/java/com/mparticle/react/NativeMPRoktSpec.kt index fa29fdf..0719dd5 100644 --- a/android/src/oldarch/java/com/mparticle/react/NativeMPRoktSpec.kt +++ b/android/src/oldarch/java/com/mparticle/react/NativeMPRoktSpec.kt @@ -3,6 +3,7 @@ package com.mparticle.react import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap abstract class NativeMPRoktSpec( @@ -17,7 +18,7 @@ abstract class NativeMPRoktSpec( abstract fun selectPlacements( identifier: String, attributes: ReadableMap?, - placeholders: ReadableMap?, + placeholders: ReadableArray?, roktConfig: ReadableMap?, fontFilesMap: ReadableMap?, ) diff --git a/android/src/oldarch/java/com/mparticle/react/rokt/MPRoktModule.kt b/android/src/oldarch/java/com/mparticle/react/rokt/MPRoktModule.kt index 6f65a20..3efca7a 100644 --- a/android/src/oldarch/java/com/mparticle/react/rokt/MPRoktModule.kt +++ b/android/src/oldarch/java/com/mparticle/react/rokt/MPRoktModule.kt @@ -3,15 +3,13 @@ package com.mparticle.react.rokt import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap -import com.facebook.react.uimanager.NativeViewHierarchyManager import com.facebook.react.uimanager.UIManagerModule import com.mparticle.MParticle import com.mparticle.internal.Logger -import com.mparticle.kits.RoktEmbeddedView import com.mparticle.kits.rokt import com.mparticle.react.NativeMPRoktSpec -import java.lang.ref.WeakReference class MPRoktModule( private val reactContext: ReactApplicationContext, @@ -24,7 +22,7 @@ class MPRoktModule( override fun selectPlacements( identifier: String, attributes: ReadableMap?, - placeholders: ReadableMap?, + placeholders: ReadableArray?, roktConfig: ReadableMap?, fontFilesMap: ReadableMap?, ) { @@ -39,12 +37,12 @@ class MPRoktModule( } val config = roktConfig?.let { impl.buildRoktConfig(it) } - uiManager?.addUIBlock { nativeViewHierarchyManager -> + uiManager?.addUIBlock { impl.whenPlaceholdersMounted(identifier, placeholders) { MParticle.getInstance()?.rokt?.selectPlacements( identifier = identifier, attributes = impl.readableMapToMapOfStrings(attributes), - embeddedViews = safeUnwrapPlaceholders(placeholders, nativeViewHierarchyManager), + embeddedViews = impl.resolvePlaceholders(placeholders), fontTypefaces = null, // TODO config = config, ) @@ -87,27 +85,4 @@ class MPRoktModule( override fun getSessionId(promise: Promise) { impl.getSessionId(promise) } - - // Positive numeric values are legacy react tags. Zero is the name-lookup sentinel used by - // the JS wrapper; unresolved tags also fall back to placeholderName. - private fun safeUnwrapPlaceholders( - placeholders: ReadableMap?, - nativeViewHierarchyManager: NativeViewHierarchyManager, - ): Map> { - val placeholderMap: MutableMap> = HashMap() - - // A for loop, not forEach: HashMap.forEach(BiConsumer) needs API 24 and minSdk is 21. - for ((key, value) in placeholders?.toHashMap().orEmpty()) { - val view = - (value as? Double)?.takeIf { it > 0 }?.let { - runCatching { nativeViewHierarchyManager.resolveView(it.toInt()) as? RoktEmbeddedView }.getOrNull() - } ?: RoktPlaceholderRegistry.lookup(key) as? RoktEmbeddedView - if (view != null) { - placeholderMap[key] = WeakReference(view) - } else { - Logger.warning("Cannot resolve placeholder for key: $key") - } - } - return placeholderMap - } } diff --git a/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt b/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt index 156266d..80a8520 100644 --- a/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt +++ b/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt @@ -1,5 +1,6 @@ package com.mparticle.react.rokt +import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.ReactApplicationContext import com.mparticle.MParticle import com.mparticle.WrapperSdk @@ -92,11 +93,11 @@ class MPRoktModuleImplTest { } @Test - fun `unmountedPlaceholderNames waits only for names, never for react tags`() { + fun `unmountedPlaceholderNames waits only for unmounted placeholderName strings`() { val mounted = Mockito.mock(android.view.View::class.java) RoktPlaceholderRegistry.register(mounted, "Mounted") try { - val placeholders = MockMap(mapOf("Location1" to 0.0, "Mounted" to 0.0, "Tagged" to 42.0)) + val placeholders = JavaOnlyArray.of("Location1", "Mounted", 42.0) assertEquals(listOf("Location1"), impl.unmountedPlaceholderNames(placeholders)) assertEquals(emptyList(), impl.unmountedPlaceholderNames(null)) diff --git a/ios/RNMParticle/RNMPRokt.mm b/ios/RNMParticle/RNMPRokt.mm index 64af67d..849c98a 100644 --- a/ios/RNMParticle/RNMPRokt.mm +++ b/ios/RNMParticle/RNMPRokt.mm @@ -12,7 +12,6 @@ #import "RoktPlaceholderRegistry.h" #ifdef RCT_NEW_ARCH_ENABLED -#import "RoktNativeLayoutComponentView.h" #import #endif // RCT_NEW_ARCH_ENABLED @@ -46,9 +45,6 @@ @interface RNMPRokt () @implementation RNMPRokt -// Maps React tags to UIViews in both bridge and bridgeless modes, unlike bridge.uiManager. -@synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; - RCT_EXTERN void RCTRegisterModule(Class); + (NSString *)moduleName { @@ -112,7 +108,7 @@ - (void)ensureEventManager { // New Architecture Implementation — selectPlacements - (void)selectPlacements:(NSString *)identifer attributes:(NSDictionary *)attributes - placeholders:(NSDictionary *)placeholders + placeholders:(NSArray *)placeholders roktConfig:(JS::NativeMPRokt::RoktConfigType &)roktConfig fontFilesMap:(NSDictionary *)fontFilesMap { @@ -123,7 +119,7 @@ - (void)selectPlacements:(NSString *)identifer RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfigDict]; #else // Old Architecture Implementation — selectPlacements -RCT_EXPORT_METHOD(selectPlacements:(NSString *) identifer attributes:(NSDictionary *)attributes placeholders:(NSDictionary * _Nullable)placeholders roktConfig:(NSDictionary * _Nullable)roktConfig fontFilesMap:(NSDictionary * _Nullable)fontFilesMap) +RCT_EXPORT_METHOD(selectPlacements:(NSString *) identifer attributes:(NSDictionary *)attributes placeholders:(NSArray * _Nullable)placeholders roktConfig:(NSDictionary * _Nullable)roktConfig fontFilesMap:(NSDictionary * _Nullable)fontFilesMap) { _rokt_log(@"[mParticle-Rokt] Old Architecture Implementation"); NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes]; @@ -311,65 +307,42 @@ - (void)getSessionIdWithResolve:(RCTPromiseResolveBlock)resolve return finalAttributes; } -// Main thread only — RCTViewRegistry and RoktPlaceholderRegistry read the mounted view hierarchy. -// A positive numeric value is a legacy findNodeHandle react tag. Zero is the name-lookup -// sentinel used by the JS wrapper; unresolved tags also fall back to placeholderName. -- (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders +// Main thread only — RoktPlaceholderRegistry reads the mounted view hierarchy. +- (NSMutableDictionary *)resolvePlaceholders:(NSArray *)placeholders { _rokt_log(@"[mParticle-Rokt] resolvePlaceholders: %lu placeholder(s)", (unsigned long)placeholders.count); NSMutableDictionary *nativePlaceholders = [[NSMutableDictionary alloc]initWithCapacity:placeholders.count]; - for(id key in placeholders){ - id reactTag = [placeholders objectForKey:key]; - UIView *embeddedView = nil; - if ([reactTag isKindOfClass:[NSNumber class]] && [reactTag integerValue] > 0) { - embeddedView = [self embeddedViewForReactTag:reactTag]; - } - if (embeddedView == nil && [key isKindOfClass:[NSString class]]) { - UIView *view = [RoktPlaceholderRegistry viewForName:key]; - // nil is not an embedded view, covering both "not mounted" and "wrong class". - if ([RNMPRoktViews isEmbeddedView:view]) { - embeddedView = view; - } + for (id name in placeholders) { + if (![name isKindOfClass:[NSString class]]) { + RCTLogError(@"Cannot resolve placeholder %@: expected a placeholderName string", name); + continue; } - if (embeddedView == nil) { - RCTLogError(@"Cannot resolve placeholder %@ (value %@)", key, reactTag); + UIView *view = [RoktPlaceholderRegistry viewForName:name]; + // nil is not an embedded view, covering both "not mounted" and "wrong class". + if (![RNMPRoktViews isEmbeddedView:view]) { + RCTLogError(@"Cannot resolve placeholder %@", name); continue; } - nativePlaceholders[key] = embeddedView; + nativePlaceholders[name] = view; } _rokt_log(@"[mParticle-Rokt] resolvePlaceholders: resolved %lu native placeholder(s)", (unsigned long)nativePlaceholders.count); return nativePlaceholders; } -// Names passed for name-based resolution (a non-positive value) that have no mounted view yet. -// Legacy react tags are never waited for, so they behave exactly as before. -+ (NSArray *)unmountedPlaceholderNames:(NSDictionary *)placeholders +// Placeholder names that have no mounted view yet. ++ (NSArray *)unmountedPlaceholderNames:(NSArray *)placeholders { NSMutableArray *pending = [NSMutableArray array]; - for (id key in placeholders) { - id value = placeholders[key]; - BOOL isReactTag = [value isKindOfClass:[NSNumber class]] && [value integerValue] > 0; - if (!isReactTag && [key isKindOfClass:[NSString class]] && [RoktPlaceholderRegistry viewForName:key] == nil) { - [pending addObject:key]; + for (id name in placeholders) { + if ([name isKindOfClass:[NSString class]] && [RoktPlaceholderRegistry viewForName:name] == nil) { + [pending addObject:name]; } } return pending; } -- (nullable UIView *)embeddedViewForReactTag:(NSNumber *)reactTag -{ - UIView *view = [_viewRegistry_DEPRECATED viewForReactTag:reactTag]; -#ifdef RCT_NEW_ARCH_ENABLED - return [view isKindOfClass:[RoktNativeLayoutComponentView class]] - ? ((RoktNativeLayoutComponentView *)view).roktEmbeddedView - : nil; -#else - return [RNMPRoktViews isEmbeddedView:view] ? view : nil; -#endif // RCT_NEW_ARCH_ENABLED -} - #ifdef RCT_NEW_ARCH_ENABLED - (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params { return std::make_shared(params); diff --git a/ios/RNMParticle/RoktPlaceholderRegistry.h b/ios/RNMParticle/RoktPlaceholderRegistry.h index a95cff6..e391b42 100644 --- a/ios/RNMParticle/RoktPlaceholderRegistry.h +++ b/ios/RNMParticle/RoktPlaceholderRegistry.h @@ -4,7 +4,7 @@ NS_ASSUME_NONNULL_BEGIN /** * Maps a `RoktLayoutView`'s `placeholderName` to its mounted `RoktEmbeddedView`, so - * `selectPlacements` can resolve placeholders by name instead of a `findNodeHandle` react tag. + * `selectPlacements` can resolve placeholders by name. * * Main thread only. Plain Objective-C so the legacy `.m` view manager can import it. */ diff --git a/js/__tests__/rokt-placeholders.test.ts b/js/__tests__/rokt-placeholders.test.ts index a2aca0e..7073f82 100644 --- a/js/__tests__/rokt-placeholders.test.ts +++ b/js/__tests__/rokt-placeholders.test.ts @@ -1,7 +1,7 @@ /** - * `selectPlacements` accepts placeholder names (`['Location1']`) or the legacy map of - * name to `findNodeHandle` react tag. The native spec only knows the map shape, so the - * name form is sent with invalid React tag zero, which native resolves by `placeholderName`. + * `selectPlacements` accepts only placeholder names (`['Location1']`). The legacy map of name + * to `findNodeHandle` react tag is rejected in JS with an error log, so every platform behaves + * the same: the placement is requested without embedded views. */ jest.mock( 'react-native', @@ -16,22 +16,29 @@ jest.mock( import { toNativePlaceholders } from '../rokt/rokt'; describe('toNativePlaceholders', () => { - it('maps placeholder names to zero for name-based resolution', () => { - expect(toNativePlaceholders(['Location1', 'Location2'])).toEqual({ - Location1: 0, - Location2: 0, - }); + afterEach(() => { + jest.restoreAllMocks(); }); - it('preserves legacy react tags and normalizes null entries', () => { - const legacy = { Location1: 42, Location2: null }; - expect(toNativePlaceholders(legacy)).toEqual({ - Location1: 42, - Location2: 0, - }); + it('passes placeholder names through unchanged', () => { + const names = ['Location1', 'Location2']; + expect(toNativePlaceholders(names)).toBe(names); }); it('passes undefined through for overlay placements', () => { expect(toNativePlaceholders(undefined)).toBeUndefined(); }); + + it('rejects the legacy map of name to react tag with an error log', () => { + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const legacy = { Location1: 42 } as unknown as string[]; + + expect(toNativePlaceholders(legacy)).toBeUndefined(); + expect(error).toHaveBeenCalledTimes(1); + expect(error.mock.calls[0][0]).toContain( + 'array of RoktLayoutView placeholderNames' + ); + }); }); diff --git a/js/codegenSpecs/rokt/NativeMPRokt.ts b/js/codegenSpecs/rokt/NativeMPRokt.ts index cfce3df..5dbb485 100644 --- a/js/codegenSpecs/rokt/NativeMPRokt.ts +++ b/js/codegenSpecs/rokt/NativeMPRokt.ts @@ -19,7 +19,7 @@ export interface Spec extends TurboModule { selectPlacements( identifier: string, attributes?: { [key: string]: RoktAttributeValue }, - placeholders?: { [key: string]: number }, + placeholders?: Array, roktConfig?: RoktConfigType, fontFilesMap?: { [key: string]: string } ): void; diff --git a/js/rokt/rokt.ts b/js/rokt/rokt.ts index ec97ef1..3c0c849 100644 --- a/js/rokt/rokt.ts +++ b/js/rokt/rokt.ts @@ -32,34 +32,28 @@ function getMPRokt(): NativeMPRoktInterface { export type RoktAttributeValue = string | number | boolean; /** - * Embedded placeholders for `selectPlacements`. - * - * Preferred: the `placeholderName`s of `RoktLayoutView`s, e.g. `['Location1']`. - * Legacy: a map of placeholder name to `findNodeHandle(ref)` react tag. Still supported. + * Embedded placeholders for `selectPlacements`: the `placeholderName`s of `RoktLayoutView`s, + * e.g. `['Location1']`. */ -export type RoktPlaceholders = string[] | Record; +export type RoktPlaceholders = string[]; /** - * Converts the public placeholder forms to the native spec's map shape. React tags are - * positive, so zero is an explicit request to resolve the view by its `placeholderName`. - * A numeric sentinel is required because React Native codegen drops null-valued map entries. + * The map of placeholder name to `findNodeHandle` react tag is no longer supported. A plain-JS + * caller that still passes one gets an error log, and the placement is requested without + * embedded views, instead of a platform-specific crash or conversion failure in native code. */ export function toNativePlaceholders( placeholders?: RoktPlaceholders -): Record | undefined { - if (placeholders == null) { - return undefined; +): string[] | undefined { + if (placeholders == null || Array.isArray(placeholders)) { + return placeholders; } - - const entries: ReadonlyArray = - Array.isArray(placeholders) - ? placeholders.map(name => [name, null] as const) - : Object.entries(placeholders); - - return entries.reduce>((map, [name, reactTag]) => { - map[name] = reactTag ?? 0; - return map; - }, {}); + console.error( + '[mParticle] selectPlacements: placeholders must be an array of RoktLayoutView placeholderNames, ' + + "e.g. ['Location1']. The map of name to findNodeHandle tag is no longer supported, so the " + + 'placement is requested without embedded views. See MIGRATING.md.' + ); + return undefined; } /** @@ -83,7 +77,7 @@ export abstract class Rokt { * * @param {string} identifier - The page identifier for the placement. * @param {Record} attributes - Attributes to be associated with the placement. - * @param {RoktPlaceholders} [placeholders] - Optional embedded placeholders: `placeholderName`s of `RoktLayoutView`s (preferred), or a legacy map of name to react tag. A named view does not need to be mounted yet: the SDK waits up to 2 seconds for it, so this can be called from the same `useEffect` that renders it. + * @param {RoktPlaceholders} [placeholders] - Optional embedded placeholders: the `placeholderName`s of `RoktLayoutView`s, e.g. `['Location1']`. A named view does not need to be mounted yet: the SDK waits up to 2 seconds for it, so this can be called from the same `useEffect` that renders it. * @param {IRoktConfig} [roktConfig] - Optional configuration settings for Rokt. * @param {Record} [fontFilesMap] - Optional mapping of font files. * @returns {Promise} A promise that resolves when the placement request is sent. diff --git a/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m index 008dfda..edf30a2 100644 --- a/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m +++ b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m @@ -1,5 +1,4 @@ #import -#import #import #import #import "../../../ios/RNMParticle/RNMPSDKImports.h" @@ -8,20 +7,13 @@ // Implemented in RNMPRokt.mm. @interface RNMPRokt (PlaceholderTests) -- (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders; +- (NSMutableDictionary *)resolvePlaceholders:(NSArray *)placeholders; ++ (NSArray *)unmountedPlaceholderNames:(NSArray *)placeholders; @end /** - * Guards how `-[RNMPRokt resolvePlaceholders:]` turns placeholder react tags into the - * embedded views handed to `MPRokt selectPlacements`. - * - * Resolution goes through `RCTViewRegistry` rather than the legacy - * `self.bridge.uiManager addUIBlock:` view registry, because the latter is a no-op method - * body when RCT_REMOVE_LEGACY_ARCH is defined (React Native 0.84's default) and a no-op - * when `self.bridge` is nil — either way selectPlacements was discarded with no event - * emitted. The registry is exercised for real here: the tests install a bridgeless - * component-view provider, the same hook RCTInstance wires to the surface presenter in - * production. + * Guards how `-[RNMPRokt resolvePlaceholders:]` turns placeholder names into the embedded + * views handed to `MPRokt selectPlacements`, looking each name up in RoktPlaceholderRegistry. * * Scope limit, deliberate: this test target statically links the react-native-mparticle * pod a second time on top of the app it hosts, so `RoktNativeLayoutComponentView` and @@ -30,18 +22,13 @@ - (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders; * warning). Asserting a mounted placeholder resolves all the way to its `RoktEmbeddedView` * would therefore be testing the linkage, not the code. That step is verified by running * an embedded placement in the sample app instead. What is covered below is - * binary-independent: that the module is wired to a working registry, and every branch - * that refuses to resolve a tag. + * binary-independent: every branch that refuses to resolve a placeholder. */ @interface RNMPRoktPlaceholderTests : XCTestCase @end @implementation RNMPRoktPlaceholderTests { RNMPRokt *_rokt; - // viewRegistry_DEPRECATED is a weak property (React Native retains the registry via - // RCTBridgeModuleDecorator for the instance's lifetime), so the test has to own it. - RCTViewRegistry *_viewRegistry; - NSMutableDictionary *_views; NSInteger _loggedErrorCount; NSMutableArray *_loggedErrors; RCTLogFunction _originalLogFunction; @@ -51,21 +38,13 @@ - (void)setUp { [super setUp]; _rokt = [RNMPRokt new]; - _views = [NSMutableDictionary new]; - - _viewRegistry = [RCTViewRegistry new]; - __weak __typeof__(self) weakSelf = self; - [_viewRegistry setBridgelessComponentViewProvider:^UIView *(NSNumber *reactTag) { - __strong __typeof__(weakSelf) strongSelf = weakSelf; - return strongSelf ? strongSelf->_views[reactTag] : nil; - }]; - _rokt.viewRegistry_DEPRECATED = _viewRegistry; // Unresolvable placeholders are reported via RCTLogError. Capture instead of letting // it surface as test noise, so the diagnostic itself can be asserted. _loggedErrorCount = 0; _loggedErrors = [NSMutableArray new]; _originalLogFunction = RCTGetLogFunction(); + __weak __typeof__(self) weakSelf = self; RCTSetLogFunction(^(RCTLogLevel level, __unused RCTLogSource source, __unused NSString *fileName, @@ -84,49 +63,36 @@ - (void)tearDown [RoktPlaceholderRegistry cancelAllWaits]; RCTSetLogFunction(_originalLogFunction); _rokt = nil; - _viewRegistry = nil; - _views = nil; [super tearDown]; } -// Regression guard for the change itself: without `@synthesize viewRegistry_DEPRECATED` -// in RNMPRokt.mm the module has no way to reach a view, and every embedded placement -// silently resolves to nothing. -- (void)testModuleIsWiredToAViewRegistryThatResolvesMountedViews +- (void)testSkipsNameWithNoMountedView { - UIView *mountedView = [[UIView alloc] init]; - _views[@101] = mountedView; - - XCTAssertNotNil(_rokt.viewRegistry_DEPRECATED); - XCTAssertEqualObjects([_rokt.viewRegistry_DEPRECATED viewForReactTag:@101], mountedView); - XCTAssertNil([_rokt.viewRegistry_DEPRECATED viewForReactTag:@999]); -} - -- (void)testSkipsTagThatIsNotMounted -{ - NSDictionary *resolved = [_rokt resolvePlaceholders:@{@"Location1" : @999}]; + NSDictionary *resolved = [_rokt resolvePlaceholders:@[ @"Location1" ]]; XCTAssertEqual(resolved.count, 0u); XCTAssertEqual(_loggedErrorCount, 1, @"errors: %@", _loggedErrors); + XCTAssertTrue([_loggedErrors.firstObject hasPrefix:@"Cannot resolve placeholder"], + @"errors: %@", _loggedErrors); } -- (void)testSkipsTagResolvingToUnexpectedViewClass +- (void)testSkipsNameRegisteredToUnexpectedViewClass { - _views[@101] = [[UIView alloc] init]; + UIView *view = [UIView new]; + [RoktPlaceholderRegistry registerView:view name:@"Location1"]; - NSDictionary *resolved = [_rokt resolvePlaceholders:@{@"Location1" : @101}]; + NSDictionary *resolved = [_rokt resolvePlaceholders:@[ @"Location1" ]]; XCTAssertEqual(resolved.count, 0u); XCTAssertEqual(_loggedErrorCount, 1, @"errors: %@", _loggedErrors); + [RoktPlaceholderRegistry unregisterView:view]; } -- (void)testSkipsNonNumericValueWithNoRegisteredNameWithoutThrowing +- (void)testSkipsNonStringEntriesWithoutThrowing { - // Defensive coverage for malformed direct native calls: viewForReactTag: would throw on - // NSNull, so non-numeric values must never reach it. They are resolved by placeholder name - // instead, and nothing is registered under these names. - NSDictionary *resolved = - [_rokt resolvePlaceholders:@{@"Location1" : [NSNull null], @"Location2" : @"101"}]; + // Defensive coverage for malformed direct native calls, such as a legacy react tag: + // only placeholderName strings are looked up. + NSDictionary *resolved = [_rokt resolvePlaceholders:@[ [NSNull null], @101 ]]; XCTAssertEqual(resolved.count, 0u); XCTAssertEqual(_loggedErrorCount, 2, @"errors: %@", _loggedErrors); @@ -134,6 +100,17 @@ - (void)testSkipsNonNumericValueWithNoRegisteredNameWithoutThrowing @"errors: %@", _loggedErrors); } +- (void)testUnmountedPlaceholderNamesListsOnlyUnregisteredStrings +{ + UIView *view = [UIView new]; + [RoktPlaceholderRegistry registerView:view name:@"Location1"]; + + NSArray *pending = [RNMPRokt unmountedPlaceholderNames:@[ @"Location1", @"Location2", @101 ]]; + + XCTAssertEqualObjects(pending, (@[ @"Location2" ])); + [RoktPlaceholderRegistry unregisterView:view]; +} + // Name-based resolution goes through RoktPlaceholderRegistry. Its semantics are // binary-independent, so they are asserted directly with plain views; the final // isKindOfClass: step hits the same linkage limit described above. @@ -216,7 +193,7 @@ - (void)testEmptyPlaceholdersResolveToEmptyDictionary { // Overlay / bottom-sheet placements pass no placeholders at all, so this path must not // depend on the view hierarchy in any way. - NSDictionary *resolved = [_rokt resolvePlaceholders:@{}]; + NSDictionary *resolved = [_rokt resolvePlaceholders:@[]]; XCTAssertEqual(resolved.count, 0u); XCTAssertEqual(_loggedErrorCount, 0, @"errors: %@", _loggedErrors); From cec4396edc4e6596d2f50bc5d9395d15f4bae111 Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:29:12 -0400 Subject: [PATCH 8/8] feat(ios)!: take the mParticle SDKs from Swift Package Manager by default (#429) Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/pull-request.yml | 25 +- MIGRATING.md | 23 +- README.md | 215 +++++++++--------- .../RoktNativeLayoutComponentView.h | 1 - .../RoktNativeLayoutComponentView.mm | 1 - ios/mparticle_spm.rb | 184 ++++++++++++--- ios/mparticle_spm_kits.json | 138 +++++++++++ js/__tests__/plugin-ios-spm.test.ts | 188 +++++++++------ plugin/src/withMParticle.ts | 17 +- plugin/src/withMParticleIOS.ts | 167 +++++++------- react-native-mparticle.podspec | 15 +- sample/ios/Podfile | 21 +- 12 files changed, 669 insertions(+), 326 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..33b6c2b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -107,6 +107,9 @@ jobs: working-directory: sample run: | rm -f yarn.lock + # Yarn 1 keeps its unpacked copy of a local .tgz under .tmp in the restored cache, keyed + # only by the file's path, so without this the sample installs an old build of this package. + rm -rf "$(yarn cache dir)/.tmp" yarn install - name: Run Android sample app @@ -126,8 +129,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 @@ -167,12 +170,29 @@ jobs: working-directory: sample run: | rm -f yarn.lock + # Yarn 1 keeps its unpacked copy of a local .tgz under .tmp in the restored cache, keyed + # only by the file's path, so without this the sample installs an old build of this package. + rm -rf "$(yarn cache dir)/.tmp" yarn install - name: Install Ruby dependencies working-directory: sample run: bundle install + # A tvOS app must get the opt-out error, not silently lose this pod (see the podspec's platforms). + - name: Check that a tvOS Podfile stops with the iOS-only error + if: matrix.ios_deps == 'spm' + working-directory: sample/ios + run: | + sed -i '' "s/^platform :ios, '15.6'/platform :tvos, '15.6'/" Podfile + if bundle exec pod install > tvos-pod-install.log 2>&1; then + cat tvos-pod-install.log + echo "pod install succeeded for a tvOS target; it should stop with the iOS-only error." + exit 1 + fi + git checkout -- Podfile + grep -F '[mParticle] Swift Package Manager mode is iOS only' tvos-pod-install.log || { cat tvos-pod-install.log; exit 1; } + - name: Build iOS sample app and run native unit tests working-directory: sample/ios run: | @@ -186,6 +206,7 @@ jobs: test \ -only-testing:MParticleSampleTests/RCTConvertCommerceMappingTests \ -only-testing:MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests \ + -only-testing:MParticleSampleTests/RoktNativeLayoutComponentViewPropsTests \ -only-testing:MParticleSampleTests/RNMParticleDuplicateSDKTests \ -only-testing:MParticleSampleTests/RNMPRoktEventMapperTests \ -only-testing:MParticleSampleTests/RNMPRoktConfigFactoryTests \ 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..17f7c01 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` and is supported only from Swift Package Manager. 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,75 @@ 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, as in a `react-native-tvos` app whose Podfile sets `platform :tvos`. Set `$RNMParticleDisableSPM = true` at the top of the Podfile, or `iosDependencyManager: 'cocoapods'` with Expo. +- `pod install` fails inside `MParticleSPM::InstallerHooks` after a CocoaPods upgrade: this package hooks CocoaPods' private `resolve_dependencies`, `validate_targets` and `run_podfile_post_install_hooks` methods, tested with CocoaPods 1.15.2 and 1.16.2. Open an issue with your CocoaPods version. +- 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 this package supports only from Swift Package Manager. + +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/RNMParticle/RoktNativeLayoutComponentView.h b/ios/RNMParticle/RoktNativeLayoutComponentView.h index fbc9421..a1d3a36 100644 --- a/ios/RNMParticle/RoktNativeLayoutComponentView.h +++ b/ios/RNMParticle/RoktNativeLayoutComponentView.h @@ -1,5 +1,4 @@ #ifdef RCT_NEW_ARCH_ENABLED -#import #import #import diff --git a/ios/RNMParticle/RoktNativeLayoutComponentView.mm b/ios/RNMParticle/RoktNativeLayoutComponentView.mm index aef0b2d..681ecf5 100644 --- a/ios/RNMParticle/RoktNativeLayoutComponentView.mm +++ b/ios/RNMParticle/RoktNativeLayoutComponentView.mm @@ -1,5 +1,4 @@ #ifdef RCT_NEW_ARCH_ENABLED -#import #import "RoktNativeLayoutComponentView.h" #import "RoktPlaceholderRegistry.h" #import "RNMPRoktSwift.h" diff --git a/ios/mparticle_spm.rb b/ios/mparticle_spm.rb index 52f299b..e7e4073 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 (with Expo, ' \ + "iosDependencyManager: 'cocoapods') 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..bcb6b9f --- /dev/null +++ b/ios/mparticle_spm_kits.json @@ -0,0 +1,138 @@ +{ + "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", + "RoktSDKPlus" + ] +} diff --git a/js/__tests__/plugin-ios-spm.test.ts b/js/__tests__/plugin-ios-spm.test.ts index 7a6b3bd..38f36ad 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,95 @@ 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.each(['mParticle-Kochava-9', 'RoktSDKPlus'])( + 'rejects %s, a Swift-package-only kit, with cocoapods', + kit => { + expect(() => + applyMParticlePodfileMods(EXPO_PODFILE, { + ...cocoaPodsProps, + iosKits: [kit], + }) + ).toThrow(`${kit} is supported only from Swift Package Manager`); + } + ); + 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 +191,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..536bc8e 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,35 @@ 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: spmOnlyKits } = readSpmKitTable(); + const swiftPackageOnly = (props.iosKits ?? []).filter(kit => + spmOnlyKits.includes(kit) + ); + if (swiftPackageOnly.length > 0) { + throw new Error( + `react-native-mparticle plugin: ${swiftPackageOnly.join( + ', ' + )} is supported only from Swift Package Manager, ` + + '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..2ef5ae2 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' @@ -13,6 +14,9 @@ Pod::Spec.new do |s| s.homepage = package['homepage'] s.license = package['license'] + # tvOS is declared in Swift Package Manager mode too, although that mode is iOS only: React Native's + # autolinking drops pods a target's platform doesn't support, so a tvOS target would silently lose + # this pod. Declared, the pod reaches MParticleSPM.check_podfile!, which stops `pod install`. s.platforms = { :ios => ios_platform, :tvos => "15.6" } s.source = { :git => "https://github.com/mParticle/react-native-mparticle.git", :tag => "#{s.version}" } @@ -24,17 +28,16 @@ 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 # against their headers and never links them, so it is a static framework even under # `use_frameworks! :linkage => :dynamic`. s.static_framework = true - s.platforms = { :ios => ios_platform } # the Rokt kit Swift package is iOS-only xcconfig['HEADER_SEARCH_PATHS'] = '"$(DERIVED_FILE_DIR)/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"' # Where Xcode puts the Swift packages' .swiftmodule files, for build and archive alike. xcconfig['SWIFT_INCLUDE_PATHS'] = '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)"' @@ -53,7 +56,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)"'