diff --git a/CHANGELOG.md b/CHANGELOG.md index b24094125c..6daef8c4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Add Replay Custom Masking for iOS ([#4224](https://github.com/getsentry/sentry-react-native/pull/4224)) + + ```jsx + import * as Sentry from '@sentry/react-native'; + + const Example = () => { + return ( + + + ${"All children of Sentry.Mask will be masked."} + + + ${"Only direct children of Sentry.Unmask will be unmasked."} + + + ); + }; + ``` + ## 6.3.0 ### Features diff --git a/packages/core/.npmignore b/packages/core/.npmignore index 6a301a4784..bf5fdefe0f 100644 --- a/packages/core/.npmignore +++ b/packages/core/.npmignore @@ -11,7 +11,13 @@ !react-native.config.js !/ios/**/* !/android/**/* + +# New Architecture Codegen !src/js/NativeRNSentry.ts +!src/js/RNSentryReplayMaskNativeComponent.ts +!src/js/RNSentryReplayUnmaskNativeComponent.ts + +# Scripts !scripts/collect-modules.sh !scripts/copy-debugid.js !scripts/has-sourcemap-debugid.js diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 59c10b17ad..f78b1be0e0 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */; }; 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; }; + 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; }; 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; }; @@ -28,6 +29,9 @@ 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayBreadcrumbConverter.h; path = ../ios/RNSentryReplayBreadcrumbConverter.h; sourceTree = ""; }; 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryBreadcrumbTests.swift; sourceTree = ""; }; 3360898D29524164007C7730 /* RNSentryCocoaTesterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RNSentryCocoaTesterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3380C6C12CDEC5850018B9B6 /* RNSentryReplayUnmask.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayUnmask.h; path = ../ios/Replay/RNSentryReplayUnmask.h; sourceTree = SOURCE_ROOT; }; + 3380C6C22CDEC6630018B9B6 /* RNSentryReplayMask.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayMask.h; path = ../ios/Replay/RNSentryReplayMask.h; sourceTree = SOURCE_ROOT; }; + 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryReplayPostInitTests.swift; sourceTree = ""; }; 338739072A7D7D2800950DDD /* RNSentryReplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplay.h; path = ../ios/RNSentryReplay.h; sourceTree = ""; }; 33958C672BFCEF5A00AD1FB6 /* RNSentryOnDrawReporter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryOnDrawReporter.h; path = ../ios/RNSentryOnDrawReporter.h; sourceTree = ""; }; 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryOnDrawReporterTests.m; sourceTree = ""; }; @@ -95,13 +99,24 @@ 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */, 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */, 332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */, + 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */, ); path = RNSentryCocoaTesterTests; sourceTree = ""; }; + 3380C6C02CDEC56B0018B9B6 /* Replay */ = { + isa = PBXGroup; + children = ( + 3380C6C22CDEC6630018B9B6 /* RNSentryReplayMask.h */, + 3380C6C12CDEC5850018B9B6 /* RNSentryReplayUnmask.h */, + ); + path = Replay; + sourceTree = ""; + }; 33AFE0122B8F319000AAB120 /* RNSentry */ = { isa = PBXGroup; children = ( + 3380C6C02CDEC56B0018B9B6 /* Replay */, 332D33482CDBDC7300547D76 /* RNSentry.h */, 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */, 330F308D2C0F385A002A0D4E /* RNSentryBreadcrumb.h */, @@ -228,6 +243,7 @@ 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */, + 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */, 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h index 43f9933853..bc2bdd0304 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h @@ -5,3 +5,5 @@ #import "RNSentryBreadcrumb.h" #import "RNSentryReplay.h" #import "RNSentryReplayBreadcrumbConverter.h" +#import "RNSentryReplayMask.h" +#import "RNSentryReplayUnmask.h" diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift index 04a825c682..4e8b35c477 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift @@ -109,11 +109,7 @@ final class RNSentryReplayOptions: XCTestCase { let sessionReplay = experimental["sessionReplay"] as! [String: Any] let maskedViewClasses = sessionReplay["maskedViewClasses"] as! [String] - XCTAssertEqual(maskedViewClasses.count, 1) - XCTAssertEqual(maskedViewClasses[0], "RNSVGSvgView") - - let actualOptions = try! Options(dict: optionsDict as! [String: Any]) - XCTAssertEqual(actualOptions.experimental.sessionReplay.maskedViewClasses.count, 0) + XCTAssertTrue(maskedViewClasses.contains("RNSVGSvgView")) } func testMaskAllImages() { @@ -128,9 +124,7 @@ final class RNSentryReplayOptions: XCTestCase { let actualOptions = try! Options(dict: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.experimental.sessionReplay.maskAllImages, true) - XCTAssertEqual(actualOptions.experimental.sessionReplay.maskedViewClasses.count, 1) - XCTAssertNotNil(actualOptions.experimental.sessionReplay.maskedViewClasses[0]) - XCTAssertEqual(ObjectIdentifier(actualOptions.experimental.sessionReplay.maskedViewClasses[0]), ObjectIdentifier(NSClassFromString("RCTImageView")!)) + assertContainsClass(classArray: actualOptions.experimental.sessionReplay.maskedViewClasses, stringClass: "RCTImageView") } func testMaskAllImagesFalse() { @@ -160,11 +154,16 @@ final class RNSentryReplayOptions: XCTestCase { let actualOptions = try! Options(dict: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.experimental.sessionReplay.maskAllText, true) - XCTAssertEqual(actualOptions.experimental.sessionReplay.maskedViewClasses.count, 2) - XCTAssertNotNil(actualOptions.experimental.sessionReplay.maskedViewClasses[0]) - XCTAssertNotNil(actualOptions.experimental.sessionReplay.maskedViewClasses[1]) - XCTAssertEqual(ObjectIdentifier(actualOptions.experimental.sessionReplay.maskedViewClasses[0]), ObjectIdentifier(NSClassFromString("RCTTextView")!)) - XCTAssertEqual(ObjectIdentifier(actualOptions.experimental.sessionReplay.maskedViewClasses[1]), ObjectIdentifier(NSClassFromString("RCTParagraphComponentView")!)) + assertContainsClass(classArray: actualOptions.experimental.sessionReplay.maskedViewClasses, stringClass: "RCTTextView") + assertContainsClass(classArray: actualOptions.experimental.sessionReplay.maskedViewClasses, stringClass: "RCTParagraphComponentView") + } + + func assertContainsClass(classArray: [AnyClass], stringClass: String) { + XCTAssertTrue(mapToObjectIdentifiers(classArray: classArray).contains(ObjectIdentifier(NSClassFromString(stringClass)!))) + } + + func mapToObjectIdentifiers(classArray: [AnyClass]) -> [ObjectIdentifier] { + return classArray.map { ObjectIdentifier($0) } } func testMaskAllTextFalse() { diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayPostInitTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayPostInitTests.swift new file mode 100644 index 0000000000..6008746256 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayPostInitTests.swift @@ -0,0 +1,13 @@ +import Sentry +import XCTest + +final class RNSentryReplayPostInitTests: XCTestCase { + + func testMask() { + XCTAssertEqual(ObjectIdentifier(RNSentryReplay.getMaskClass()), ObjectIdentifier(RNSentryReplayMask.self)) + } + + func testUnmask() { + XCTAssertEqual(ObjectIdentifier(RNSentryReplay.getUnmaskClass()), ObjectIdentifier(RNSentryReplayUnmask.self)) + } +} diff --git a/packages/core/ios/RNSentryReplay.h b/packages/core/ios/RNSentryReplay.h index f3cb5a6ef8..452914af15 100644 --- a/packages/core/ios/RNSentryReplay.h +++ b/packages/core/ios/RNSentryReplay.h @@ -5,4 +5,8 @@ + (void)postInit; ++ (Class)getMaskClass; + ++ (Class)getUnmaskClass; + @end diff --git a/packages/core/ios/RNSentryReplay.m b/packages/core/ios/RNSentryReplay.mm similarity index 72% rename from packages/core/ios/RNSentryReplay.m rename to packages/core/ios/RNSentryReplay.mm index 9cb17d612a..8c941741c4 100644 --- a/packages/core/ios/RNSentryReplay.m +++ b/packages/core/ios/RNSentryReplay.mm @@ -1,5 +1,9 @@ #import "RNSentryReplay.h" -#import "RNSentryReplayBreadcrumbConverter.h" +#import "RNSentryReplayBreadcrumbConverterHelper.h" +#import "React/RCTTextView.h" +#import "Replay/RNSentryReplayMask.h" +#import "Replay/RNSentryReplayUnmask.h" +#import #if SENTRY_TARGET_REPLAY_SUPPORTED @@ -56,9 +60,21 @@ + (NSArray *_Nonnull)getReplayRNRedactClasses:(NSDictionary *_Nullable)replayOpt + (void)postInit { - RNSentryReplayBreadcrumbConverter *breadcrumbConverter = - [[RNSentryReplayBreadcrumbConverter alloc] init]; - [PrivateSentrySDKOnly configureSessionReplayWith:breadcrumbConverter screenshotProvider:nil]; + // We can't import RNSentryReplayMask.h here because it's Objective-C++ + // To avoid typos, we test the class existence in the tests + [PrivateSentrySDKOnly setRedactContainerClass:[RNSentryReplay getMaskClass]]; + [PrivateSentrySDKOnly setIgnoreContainerClass:[RNSentryReplay getUnmaskClass]]; + [RNSentryReplayBreadcrumbConverterHelper configureSessionReplayWithConverter]; +} + ++ (Class)getMaskClass +{ + return RNSentryReplayMask.class; +} + ++ (Class)getUnmaskClass +{ + return RNSentryReplayUnmask.class; } @end diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.h b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.h new file mode 100644 index 0000000000..727cf17dc7 --- /dev/null +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.h @@ -0,0 +1,7 @@ +#import + +@interface RNSentryReplayBreadcrumbConverterHelper : NSObject + ++ (void)configureSessionReplayWithConverter; + +@end diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m new file mode 100644 index 0000000000..ef3da7ec38 --- /dev/null +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m @@ -0,0 +1,17 @@ +#import "RNSentryReplayBreadcrumbConverterHelper.h" + +#if SENTRY_TARGET_REPLAY_SUPPORTED +# import "RNSentryReplayBreadcrumbConverter.h" + +@implementation RNSentryReplayBreadcrumbConverterHelper + ++ (void)configureSessionReplayWithConverter +{ + RNSentryReplayBreadcrumbConverter *breadcrumbConverter = + [[RNSentryReplayBreadcrumbConverter alloc] init]; + [PrivateSentrySDKOnly configureSessionReplayWith:breadcrumbConverter screenshotProvider:nil]; +} + +@end + +#endif diff --git a/packages/core/ios/Replay/RNSentryReplayMask.h b/packages/core/ios/Replay/RNSentryReplayMask.h new file mode 100644 index 0000000000..3903d413e7 --- /dev/null +++ b/packages/core/ios/Replay/RNSentryReplayMask.h @@ -0,0 +1,24 @@ +#import + +#if SENTRY_HAS_UIKIT + +# import + +# ifdef RCT_NEW_ARCH_ENABLED +# import +# else +# import +# endif + +@interface RNSentryReplayMaskManager : RCTViewManager +@end + +@interface RNSentryReplayMask : +# ifdef RCT_NEW_ARCH_ENABLED + RCTViewComponentView +# else + RCTView +# endif +@end + +#endif diff --git a/packages/core/ios/Replay/RNSentryReplayMask.mm b/packages/core/ios/Replay/RNSentryReplayMask.mm new file mode 100644 index 0000000000..bc39f229e2 --- /dev/null +++ b/packages/core/ios/Replay/RNSentryReplayMask.mm @@ -0,0 +1,51 @@ +#import + +#if SENTRY_HAS_UIKIT + +# import "RNSentryReplayMask.h" + +# ifdef RCT_NEW_ARCH_ENABLED +# import +# import +// RCTFabricComponentsPlugins needed for RNSentryReplayMaskCls +# import +# endif + +@implementation RNSentryReplayMaskManager + +RCT_EXPORT_MODULE(RNSentryReplayMask) + +- (UIView *)view +{ + return [RNSentryReplayMask new]; +} + +@end + +# ifdef RCT_NEW_ARCH_ENABLED +@interface +RNSentryReplayMask () +@end +# endif + +@implementation RNSentryReplayMask + +# ifdef RCT_NEW_ARCH_ENABLED ++ (facebook::react::ComponentDescriptorProvider)componentDescriptorProvider +{ + return facebook::react::concreteComponentDescriptorProvider< + facebook::react::RNSentryReplayMaskComponentDescriptor>(); +} +# endif + +@end + +# ifdef RCT_NEW_ARCH_ENABLED +Class +RNSentryReplayMaskCls(void) +{ + return RNSentryReplayMask.class; +} +# endif + +#endif diff --git a/packages/core/ios/Replay/RNSentryReplayUnmask.h b/packages/core/ios/Replay/RNSentryReplayUnmask.h new file mode 100644 index 0000000000..c1dc8a3479 --- /dev/null +++ b/packages/core/ios/Replay/RNSentryReplayUnmask.h @@ -0,0 +1,24 @@ +#import + +#if SENTRY_HAS_UIKIT + +# import + +# ifdef RCT_NEW_ARCH_ENABLED +# import +# else +# import +# endif + +@interface RNSentryReplayUnmaskManager : RCTViewManager +@end + +@interface RNSentryReplayUnmask : +# ifdef RCT_NEW_ARCH_ENABLED + RCTViewComponentView +# else + RCTView +# endif +@end + +#endif diff --git a/packages/core/ios/Replay/RNSentryReplayUnmask.mm b/packages/core/ios/Replay/RNSentryReplayUnmask.mm new file mode 100644 index 0000000000..8dd0f06611 --- /dev/null +++ b/packages/core/ios/Replay/RNSentryReplayUnmask.mm @@ -0,0 +1,51 @@ +#import + +#if SENTRY_HAS_UIKIT + +# import "RNSentryReplayUnmask.h" + +# ifdef RCT_NEW_ARCH_ENABLED +# import +# import +// RCTFabricComponentsPlugins needed for RNSentryReplayUnmaskCls +# import +# endif + +@implementation RNSentryReplayUnmaskManager + +RCT_EXPORT_MODULE(RNSentryReplayUnmask) + +- (UIView *)view +{ + return [RNSentryReplayUnmask new]; +} + +@end + +# ifdef RCT_NEW_ARCH_ENABLED +@interface +RNSentryReplayUnmask () +@end +# endif + +@implementation RNSentryReplayUnmask + +# ifdef RCT_NEW_ARCH_ENABLED ++ (facebook::react::ComponentDescriptorProvider)componentDescriptorProvider +{ + return facebook::react::concreteComponentDescriptorProvider< + facebook::react::RNSentryReplayUnmaskComponentDescriptor>(); +} +# endif + +@end + +# ifdef RCT_NEW_ARCH_ENABLED +Class +RNSentryReplayUnmaskCls(void) +{ + return RNSentryReplayUnmask.class; +} +# endif + +#endif diff --git a/packages/core/package.json b/packages/core/package.json index 85df713ac4..4c08268a34 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -118,7 +118,7 @@ }, "codegenConfig": { "name": "RNSentrySpec", - "type": "modules", + "type": "all", "jsSrcsDir": "src", "android": { "javaPackageName": "io.sentry.react" diff --git a/packages/core/src/js/RNSentryReplayMaskNativeComponent.ts b/packages/core/src/js/RNSentryReplayMaskNativeComponent.ts new file mode 100644 index 0000000000..794dcb2e8c --- /dev/null +++ b/packages/core/src/js/RNSentryReplayMaskNativeComponent.ts @@ -0,0 +1,10 @@ +import type { HostComponent, ViewProps } from 'react-native'; +// The default export exists in the file but eslint doesn't see it +// eslint-disable-next-line import/default +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; + +// If changed to type NativeProps = ViewProps, react native codegen will fail finding the NativeProps type +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface NativeProps extends ViewProps {} + +export default codegenNativeComponent('RNSentryReplayMask') as HostComponent; diff --git a/packages/core/src/js/RNSentryReplayUnmaskNativeComponent.ts b/packages/core/src/js/RNSentryReplayUnmaskNativeComponent.ts new file mode 100644 index 0000000000..928499c747 --- /dev/null +++ b/packages/core/src/js/RNSentryReplayUnmaskNativeComponent.ts @@ -0,0 +1,10 @@ +import type { HostComponent, ViewProps } from 'react-native'; +// The default export exists in the file but eslint doesn't see it +// eslint-disable-next-line import/default +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; + +// If changed to type NativeProps = ViewProps, react native codegen will fail finding the NativeProps type +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface NativeProps extends ViewProps {} + +export default codegenNativeComponent('RNSentryReplayUnmask') as HostComponent; diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 89ed3b6412..b52131fbd6 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -78,3 +78,5 @@ export { } from './tracing'; export type { TimeToDisplayProps } from './tracing'; + +export { Mask, Unmask } from './replay/CustomMask'; diff --git a/packages/core/src/js/replay/CustomMask.tsx b/packages/core/src/js/replay/CustomMask.tsx new file mode 100644 index 0000000000..df4e3c1cfc --- /dev/null +++ b/packages/core/src/js/replay/CustomMask.tsx @@ -0,0 +1,63 @@ +import { logger } from '@sentry/utils'; +import * as React from 'react'; +import type { HostComponent, ViewProps } from 'react-native'; +import { UIManager, View } from 'react-native'; + +const NativeComponentRegistry: { + get>(componentName: string, createViewConfig: () => C): HostComponent; +// eslint-disable-next-line @typescript-eslint/no-var-requires +} = require('react-native/Libraries/NativeComponent/NativeComponentRegistry'); + +const MaskNativeComponentName = 'RNSentryReplayMask'; +const UnmaskNativeComponentName = 'RNSentryReplayUnmask'; + +const warnMessage = (component: string): string => `[SentrySessionReplay] ${component} component is not supported on the current platform. If ${component} should be supported, please ensure that the application build is up to date.`; + +const warn = (component: string): void => { + setTimeout(() => { + // Missing mask component could cause leaking PII, we have to ensure that the warning is visible + // even if the app is running without debug. + // eslint-disable-next-line no-console + console.warn(warnMessage(component)); + }, 0); +}; + +const MaskFallback = (viewProps: ViewProps): React.ReactElement => { + warn('Mask'); + return ; +}; + +const UnmaskFallback = (viewProps: ViewProps): React.ReactElement => { + warn('Unmask'); + return ; +}; + +const hasViewManagerConfig = (nativeComponentName: string): boolean => UIManager.hasViewManagerConfig && UIManager.hasViewManagerConfig(nativeComponentName); + +const Mask = ((): HostComponent | React.ComponentType => { + if (!hasViewManagerConfig(MaskNativeComponentName)) { + logger.warn(`[SentrySessionReplay] Can't load ${MaskNativeComponentName}.`); + return MaskFallback; + } + + // Based on @react-native/babel-plugin-codegen output + // https://github.com/facebook/react-native/blob/b32c6c2cc1bc566a85a883901dbf5e23b5a75b61/packages/react-native-codegen/src/generators/components/GenerateViewConfigJs.js#L139 + return NativeComponentRegistry.get(MaskNativeComponentName, () => ({ + uiViewClassName: MaskNativeComponentName, + })); +})() + +const Unmask = ((): HostComponent | React.ComponentType => { + if (!hasViewManagerConfig(UnmaskNativeComponentName)) { + logger.warn(`[SentrySessionReplay] Can't load ${UnmaskNativeComponentName}.`); + return UnmaskFallback; + } + + // Based on @react-native/babel-plugin-codegen output + // https://github.com/facebook/react-native/blob/b32c6c2cc1bc566a85a883901dbf5e23b5a75b61/packages/react-native-codegen/src/generators/components/GenerateViewConfigJs.js#L139 + return NativeComponentRegistry.get(UnmaskNativeComponentName, () => ({ + uiViewClassName: UnmaskNativeComponentName, + })); +})(); + +export { Mask, Unmask, MaskFallback, UnmaskFallback }; diff --git a/packages/core/src/js/replay/CustomMask.web.tsx b/packages/core/src/js/replay/CustomMask.web.tsx new file mode 100644 index 0000000000..87275b9986 --- /dev/null +++ b/packages/core/src/js/replay/CustomMask.web.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { ViewProps } from 'react-native'; +import { View } from 'react-native'; + +const Mask = (props: ViewProps): React.ReactElement => { + // We have to ensure that the warning is visible even if the app is running without debug + // eslint-disable-next-line no-console + console.warn('[SentrySessionReplay] Mask component is not supported on web.'); + return ; +}; +const Unmask = (props: ViewProps): React.ReactElement => { + // We have to ensure that the warning is visible even if the app is running without debug + // eslint-disable-next-line no-console + console.warn('[SentrySessionReplay] Unmask component is not supported on web.'); + return ; +}; + +export { Mask, Unmask }; diff --git a/packages/core/test/replay/CustomMask.test.ts b/packages/core/test/replay/CustomMask.test.ts new file mode 100644 index 0000000000..7ae7978665 --- /dev/null +++ b/packages/core/test/replay/CustomMask.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; + +describe('CustomMask', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('returns a fallback when native view manager is missing', () => { + jest.mock('react-native', () => ({ + UIManager: {}, + View: jest.fn(), + })); + + const { Mask, Unmask, MaskFallback, UnmaskFallback } = require('../../src/js/replay/CustomMask'); + + expect(Mask).toBe(MaskFallback); + expect(Unmask).toBe(UnmaskFallback); + }); + + it('returns a fallback component when native view manager config is missing', () => { + jest.mock('react-native', () => ({ + UIManager: { + hasViewManagerConfig: () => false, + }, + View: jest.fn(), + })); + + const { Mask, Unmask, MaskFallback, UnmaskFallback } = require('../../src/js/replay/CustomMask'); + + expect(Mask).toBe(MaskFallback); + expect(Unmask).toBe(UnmaskFallback); + }); + + it('returns native components when native components exist', () => { + const mockMaskComponent = jest.fn(); + const mockUnmaskComponent = jest.fn(); + const mockNativeComponentRegistryGet = jest.fn().mockImplementation((componentName: string) => { + if (componentName === 'RNSentryReplayMask') { + return mockMaskComponent; + } else if (componentName === 'RNSentryReplayUnmask') { + return mockUnmaskComponent; + } else { + throw new Error(`Unknown component name: ${componentName}`); + } + }); + + jest.mock('react-native/Libraries/NativeComponent/NativeComponentRegistry', () => ({ + get: mockNativeComponentRegistryGet, + })); + + jest.mock('react-native', () => ({ + UIManager: { + hasViewManagerConfig: () => true, + }, + View: jest.fn(), + })); + + const { Mask, Unmask } = require('../../src/js/replay/CustomMask'); + + expect(Mask).toBe(mockMaskComponent); + expect(Unmask).toBe(mockUnmaskComponent); + + expect(mockNativeComponentRegistryGet).toBeCalledTimes(2); + expect(mockNativeComponentRegistryGet).toBeCalledWith('RNSentryReplayMask', expect.any(Function)); + expect(mockNativeComponentRegistryGet).toBeCalledWith('RNSentryReplayUnmask', expect.any(Function)); + }); +}); diff --git a/packages/core/test/replay/CustomMask.web.test.ts b/packages/core/test/replay/CustomMask.web.test.ts new file mode 100644 index 0000000000..1ee8d61e99 --- /dev/null +++ b/packages/core/test/replay/CustomMask.web.test.ts @@ -0,0 +1,10 @@ +import { describe } from '@jest/globals'; + +describe('CustomMask', () => { + it('returns a react native view', () => { + const { Mask, Unmask } = require('../../src/js/replay/CustomMask'); + + expect(Mask).toBeDefined(); + expect(Unmask).toBeDefined(); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index b0a099d1e9..7f757b53ce 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -4,6 +4,7 @@ "src/js/tools/**/*.ts", "src/js/*.ts", "src/js/**/*.web.ts", + "src/js/**/*.web.tsx", "src/js/*.tsx", "test/**/*.ts", "test/**/*.tsx", diff --git a/samples/react-native-macos/src/Screens/PlaygroundScreen.tsx b/samples/react-native-macos/src/Screens/PlaygroundScreen.tsx index 7a5d212892..96575f2aa9 100644 --- a/samples/react-native-macos/src/Screens/PlaygroundScreen.tsx +++ b/samples/react-native-macos/src/Screens/PlaygroundScreen.tsx @@ -14,6 +14,7 @@ import { Pressable, } from 'react-native'; import SvgGraphic from '../components/SvgGraphic'; +import * as Sentry from '@sentry/react-native'; const multilineText = `This is @@ -32,6 +33,16 @@ const PlaygroundScreen = () => { Text: {'This is '} + Custom Mask: + + {/* Replay is not supported on macOS. This sample demonstrates that unavailable components do not cause the app to crash. */} + + This is masked + + + This is unmasked + + TextInput: { props.navigation.navigate('Webview'); }} /> + Custom Mask: + + + This is unmasked because it's direct child of Sentry.Unmask (can be masked if Sentry.Masked is used higher in the hierarchy) + + This is masked always because it's a child of a Sentry.Mask + + {/* Sentry.Unmask does not override the Sentry.Mask from above in the hierarchy */} + This is masked always because it's a child of Sentry.Mask + + + + Text: {'This is '}