From a0e14caa5573c10be2713c7021e9f2892c5620d2 Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Mon, 28 Sep 2026 15:49:18 -0400 Subject: [PATCH] feat(ios): warn in debug builds when two mParticle SDK copies are loaded Taking the mParticle core from CocoaPods and a kit from Swift Package Manager links the SDK twice. The app builds and archives silently, then crashes at runtime. In Debug builds, RNMParticle now counts the app-bundle images that define MParticle and RoktEmbeddedView the first time the module is created, and raises a red box naming the duplicates. Release builds contain none of it. Adds an XCTest for the counting helper (and runs it in CI), and a short README section the message points to. 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