diff --git a/CHANGELOG.md b/CHANGELOG.md index 4390d1fb4e..33f2a40b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Changes + +- Migrate iOS code from the deprecated `PrivateSentrySDKOnly` SPI (and `SentrySwizzle.h` macro) to the new `SentrySDK.internal` Swift API, via a thin in-pod ObjC↔Swift bridge ([#6541](https://github.com/getsentry/sentry-react-native/pull/6541)) + + This re-lands [#6380](https://github.com/getsentry/sentry-react-native/pull/6380), which shipped in 8.19.0 and was reverted in 8.20.0 ([#6491](https://github.com/getsentry/sentry-react-native/pull/6491)) because it broke all iOS screenshot capture ([#6497](https://github.com/getsentry/sentry-react-native/issues/6497)). The root cause was in sentry-cocoa — `SentryDependencyContainer.screenshotSource` was a `lazy var` that permanently cached `nil` when first read before `SentrySDK.start` — and is fixed in sentry-cocoa 9.24.0 ([getsentry/sentry-cocoa#8578](https://github.com/getsentry/sentry-cocoa/pull/8578)), which this release bumps to. + + Re-landing also restores the two improvements the revert removed: `fetchViewHierarchy` now returns `null` instead of a zero-byte attachment when capture fails, and `RNSentryTimeToDisplay`'s shared state uses `extern` linkage. + + +> [!IMPORTANT] +> **iOS:** On React Native versions where `React-hermes` (or another RN pod) is not modularized by default (e.g. RN 0.71), add `use_modular_headers!` to your `ios/Podfile` above the `target` block. This is required because the `RNSentry` pod now contains Swift code (the `RNSentryInternal` bridge over `SentrySDK.internal`) and CocoaPods refuses to integrate a Swift pod against non-modular ObjC dependencies. Newer React Native versions require no change. + + +### Dependencies + +- Bump Cocoa SDK from v9.19.1 to v9.24.0 ([#6541](https://github.com/getsentry/sentry-react-native/pull/6541)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#9240) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/9.19.1...9.24.0) + ## 8.21.0 ### Features diff --git a/dev-packages/e2e-tests/patch-scripts/rn.patch.podfile.js b/dev-packages/e2e-tests/patch-scripts/rn.patch.podfile.js index 8ab2afa36b..c488ef39fe 100755 --- a/dev-packages/e2e-tests/patch-scripts/rn.patch.podfile.js +++ b/dev-packages/e2e-tests/patch-scripts/rn.patch.podfile.js @@ -63,8 +63,27 @@ if (currentMatch) { debug.log('Warning: Could not find platform :ios line to patch'); } +// RNSentry now contains Swift code (via the RNSentryInternal bridge over +// SentrySDK.internal). CocoaPods refuses to integrate a Swift pod against +// non-modular ObjC dependencies (e.g. React-hermes on older RN versions), +// so ensure the Podfile requests modular headers globally. +let modularPatched = false; +if (!content.includes('use_modular_headers!')) { + const patched = content.replace( + /prepare_react_native_project!\s*\n/, + "prepare_react_native_project!\nuse_modular_headers!\n", + ); + if (patched !== content) { + content = patched; + modularPatched = true; + debug.log('Patching Podfile with use_modular_headers!'); + } else { + debug.log('Warning: Could not find prepare_react_native_project! anchor to inject use_modular_headers!'); + } +} + // Write the file if any changes were made -if (shouldPatch || currentMatch) { +if (shouldPatch || currentMatch || modularPatched) { fs.writeFileSync(args['pod-file'], content); debug.log('Podfile patched successfully!'); } else { diff --git a/packages/core/RNSentry.podspec b/packages/core/RNSentry.podspec index 4162b140dd..70f5a136d3 100644 --- a/packages/core/RNSentry.podspec +++ b/packages/core/RNSentry.podspec @@ -46,23 +46,15 @@ Pod::Spec.new do |s| # in `android/CMakeLists.txt`. The files are guarded with # `RCT_NEW_ARCH_ENABLED` so they compile to empty TUs on Old Arch. # - # We include `.swift` (for `RNSentrySwiftLinkStub.swift`) only on RN >= - # 0.75. Adding a Swift file makes CocoaPods treat RNSentry as a Swift - # pod, which then requires modular headers from its ObjC dependencies - # (React-Core, React-hermes) — RN < 0.75 doesn't emit those, so - # `pod install` fails with: - # "The Swift pod `RNSentry` depends upon `React-hermes`, which does - # not define modules." - # The stub is only needed when linking Sentry.xcframework's Swift - # symbols into a dynamic framework anyway (RN 0.86+ `use_frameworks! - # :dynamic`), so gating on RN 0.75 is safe. - supports_swift_stub = rn_version[:major] >= 1 || (rn_version[:major] == 0 && rn_version[:minor] >= 75) - if supports_swift_stub - s.source_files = 'ios/**/*.{h,m,mm,swift}', 'cpp/**/*.{h,cpp}' - s.swift_versions = ['5.5'] - else - s.source_files = 'ios/**/*.{h,m,mm}', 'cpp/**/*.{h,cpp}' - end + # Swift is compiled unconditionally because `RNSentryInternal.swift` is + # the sole ObjC↔Swift bridge over `SentrySDK.internal.*` — every `.m`/ + # `.mm` file in this pod calls into it. That makes RNSentry a Swift pod + # in CocoaPods' eyes, which in turn requires modular headers from its + # ObjC dependencies. Users on React Native < 0.75 (where `React-hermes` + # and friends aren't modularized by default) must add + # `use_modular_headers!` to their Podfile — see CHANGELOG. + s.source_files = 'ios/**/*.{h,m,mm,swift}', 'cpp/**/*.{h,cpp}' + s.swift_versions = ['5.5'] s.public_header_files = 'ios/RNSentry.h', 'ios/RNSentrySDK.h', 'ios/RNSentryStart.h', 'ios/RNSentryVersion.h', 'ios/RNSentryBreadcrumb.h', 'ios/RNSentryReplay.h', 'ios/RNSentryReplayBreadcrumbConverter.h', 'ios/Replay/RNSentryReplayMask.h', 'ios/Replay/RNSentryReplayUnmask.h', 'ios/RNSentryTimeToDisplay.h' s.compiler_flags = other_cflags @@ -71,7 +63,7 @@ Pod::Spec.new do |s| 'DEFINES_MODULE' => 'YES' } - sentry_cocoa_version = '9.19.1' + sentry_cocoa_version = '9.24.0' # Consume sentry-cocoa as a prebuilt `Sentry.xcframework` by default. # diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 8c47e431b6..508193ce80 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */; }; 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */; }; 33F58AD02977037D008F60EA /* RNSentryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.m */; }; + 33F58AD12977037D008F60EB /* RNSentryScreenshotSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACE2977037D008F60EB /* RNSentryScreenshotSourceTests.swift */; }; 2639D71D3BD04F17B0BAC987 /* RNSentryTurboModulePerfControllerTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = E795057A6D534A80A9D06356 /* RNSentryTurboModulePerfControllerTests.mm */; }; AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */; }; B4DEB41739F14AA38202D4D4 /* RNSentryUriValidationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */; }; @@ -50,6 +51,7 @@ 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryTimeToDisplay.h; path = ../ios/RNSentryTimeToDisplay.h; sourceTree = SOURCE_ROOT; }; 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryTimeToDisplayTests.swift; sourceTree = ""; }; 33F58ACF2977037D008F60EA /* RNSentryTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNSentryTests.m; sourceTree = ""; }; + 33F58ACE2977037D008F60EB /* RNSentryScreenshotSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryScreenshotSourceTests.swift; sourceTree = ""; }; 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryUriValidationTests.m; sourceTree = ""; }; 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNSentryCocoaTesterTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; E795057A6D534A80A9D06356 /* RNSentryTurboModulePerfControllerTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RNSentryTurboModulePerfControllerTests.mm; sourceTree = ""; }; @@ -111,6 +113,7 @@ 332D33492CDCC8E100547D76 /* RNSentryTests.h */, 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */, 33F58ACF2977037D008F60EA /* RNSentryTests.m */, + 33F58ACE2977037D008F60EB /* RNSentryScreenshotSourceTests.swift */, 3339C4802D6625570088EB3A /* RNSentryUserTests.m */, 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */, E795057A6D534A80A9D06356 /* RNSentryTurboModulePerfControllerTests.mm */, @@ -267,6 +270,7 @@ 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.m in Sources */, + 33F58AD12977037D008F60EB /* RNSentryScreenshotSourceTests.swift in Sources */, 3339C4812D6625570088EB3A /* RNSentryUserTests.m in Sources */, B4DEB41739F14AA38202D4D4 /* RNSentryUriValidationTests.m in Sources */, 2639D71D3BD04F17B0BAC987 /* RNSentryTurboModulePerfControllerTests.mm in Sources */, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift index 7d2c99c7b3..da7f1ccad1 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift @@ -1,4 +1,4 @@ -import Sentry +@_spi(Private) import Sentry import XCTest // File length grows as replay option coverage is added; lint runs with `--strict`. @@ -117,7 +117,7 @@ final class RNSentryReplayOptions: XCTestCase { ] as NSDictionary).mutableCopy() as! NSMutableDictionary RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.sessionSampleRate, 0.75) } @@ -128,7 +128,7 @@ final class RNSentryReplayOptions: XCTestCase { ] as NSDictionary).mutableCopy() as! NSMutableDictionary RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.onErrorSampleRate, 0.75) } @@ -158,7 +158,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.maskAllImages, true) assertContainsClass(classArray: actualOptions.sessionReplay.maskedViewClasses, stringClass: "RCTImageView") @@ -173,7 +173,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.maskAllImages, false) XCTAssertEqual(actualOptions.sessionReplay.maskedViewClasses.count, 0) @@ -188,7 +188,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.maskAllText, true) assertContainsClass(classArray: actualOptions.sessionReplay.maskedViewClasses, stringClass: "RCTTextView") @@ -215,7 +215,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.maskAllText, false) XCTAssertEqual(actualOptions.sessionReplay.maskedViewClasses.count, 0) @@ -229,7 +229,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertTrue(actualOptions.sessionReplay.enableViewRendererV2) } @@ -243,7 +243,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertTrue(actualOptions.sessionReplay.enableViewRendererV2) } @@ -257,7 +257,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertFalse(actualOptions.sessionReplay.enableViewRendererV2) } @@ -270,7 +270,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertFalse(actualOptions.sessionReplay.enableFastViewRendering) } @@ -284,7 +284,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertTrue(actualOptions.sessionReplay.enableFastViewRendering) } @@ -298,7 +298,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertFalse(actualOptions.sessionReplay.enableFastViewRendering) } @@ -311,7 +311,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.quality, SentryReplayOptions.SentryReplayQuality.medium) } @@ -325,7 +325,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.quality, SentryReplayOptions.SentryReplayQuality.low) } @@ -339,7 +339,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.quality, SentryReplayOptions.SentryReplayQuality.medium) } @@ -353,7 +353,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.quality, SentryReplayOptions.SentryReplayQuality.high) } @@ -367,7 +367,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) XCTAssertEqual(actualOptions.sessionReplay.quality, SentryReplayOptions.SentryReplayQuality.medium) } @@ -381,7 +381,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) let includedViewClasses = actualOptions.sessionReplay.includedViewClasses XCTAssertEqual(includedViewClasses.count, 3) @@ -399,7 +399,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) let excludedViewClasses = actualOptions.sessionReplay.excludedViewClasses XCTAssertEqual(excludedViewClasses.count, 3) @@ -420,7 +420,7 @@ final class RNSentryReplayOptions: XCTestCase { RNSentryReplay.updateOptions(optionsDict) - let actualOptions = try! PrivateSentrySDKOnly.options(with: optionsDict as! [String: Any]) + let actualOptions = try! SentrySDK.internal.options(fromDictionary: optionsDict as! [String: Any]) let includedViewClasses = actualOptions.sessionReplay.includedViewClasses XCTAssertEqual(includedViewClasses.count, 2) diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryScreenshotSourceTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryScreenshotSourceTests.swift new file mode 100644 index 0000000000..503685a3b7 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryScreenshotSourceTests.swift @@ -0,0 +1,47 @@ +@_spi(Private) import Sentry +import XCTest + +/// Guards the iOS screenshot regression from 8.19.0 (#6497), which forced the revert +/// of the `SentrySDK.internal` migration (#6380) in 8.20.0. +/// +/// RN reads `SentrySDK.internal` before `SentrySDK.start`: `RNSentryStart` does it +/// itself, and JS integrations call native methods (`fetchNativeAppStart`, +/// `fetchNativeSdkInfo`) while the SDK is still initializing. Constructing +/// `SentryInternalApi` eagerly reads `SentryDependencyContainer.screenshotSource`, +/// which used to be a `lazy var` that permanently cached the `nil` its builder returns +/// while `startOptions` is unset. That killed every iOS screenshot for the rest of the +/// process — Feedback Widget screenshot, `attachScreenshot` and +/// `Sentry.captureScreenshot()` all silently returned nothing. +/// +/// Fixed in sentry-cocoa 9.24.0 (getsentry/sentry-cocoa#8578) by turning +/// `screenshotSource` into a computed property that rebuilds until `startOptions` +/// is available. This test fails against any earlier version. +final class RNSentryScreenshotSourceTests: XCTestCase { + + func testScreenshotCaptureSurvivesInternalApiAccessBeforeStart() throws { + // Model a process that has not started the SDK yet. + SentrySDK.close() + SentryDependencyContainer.reset() + // `reset()` deliberately carries `startOptions` over to the new container, so + // clear it explicitly. + SentrySDK.setStart(with: nil) + + // The pre-start access that used to poison the screenshot source. + _ = SentrySDK.internal + + var error: NSError? + RNSentryStart.start( + options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "attachScreenshot": true + ], + error: &error) + if let error = error { + throw error + } + + XCTAssertNotNil( + SentrySDK.internal.screenshot.capture(), + "screenshot capture must still work after SentrySDK.internal was read before SentrySDK.start") + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift index e0269a5961..38107d3f66 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift @@ -1,3 +1,4 @@ +@_spi(Private) import Sentry import XCTest final class RNSentryStartFromFileTests: XCTestCase { @@ -11,7 +12,7 @@ final class RNSentryStartFromFileTests: XCTestCase { XCTAssertTrue(wasConfigurationCalled) - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options XCTAssertNil(actualOptions.dsn) XCTAssertNil(actualOptions.parsedDsn) } @@ -25,7 +26,7 @@ final class RNSentryStartFromFileTests: XCTestCase { XCTAssertTrue(wasConfigurationCalled) - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options XCTAssertNil(actualOptions.dsn) XCTAssertNil(actualOptions.parsedDsn) } @@ -39,7 +40,7 @@ final class RNSentryStartFromFileTests: XCTestCase { XCTAssertTrue(wasConfigurationCalled) - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options XCTAssertNil(actualOptions.dsn) XCTAssertNotNil(actualOptions.parsedDsn) XCTAssertEqual(actualOptions.environment, "environment-from-invalid-file") @@ -54,7 +55,7 @@ final class RNSentryStartFromFileTests: XCTestCase { XCTAssertTrue(wasConfigurationCalled) - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options XCTAssertNil(actualOptions.dsn) XCTAssertNotNil(actualOptions.parsedDsn) XCTAssertEqual(actualOptions.environment, "environment-from-valid-file") @@ -76,7 +77,7 @@ final class RNSentryStartFromFileTests: XCTestCase { options.environment = "new-environment" } - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options XCTAssertEqual(actualOptions.environment, "new-environment") } diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift index c437b83049..682c651936 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift @@ -1,3 +1,4 @@ +@_spi(Private) import Sentry import XCTest final class RNSentryStartTests: XCTestCase { @@ -29,7 +30,7 @@ final class RNSentryStartTests: XCTestCase { "dsn": "https://abcd@efgh.ingest.sentry.io/123456" ]) - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options assertReactDefaults(actualOptions) } @@ -63,10 +64,10 @@ final class RNSentryStartTests: XCTestCase { for startMethod in testCases { try startMethod() - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options - XCTAssertTrue(PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode) - XCTAssertTrue(PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode) + XCTAssertTrue(SentrySDK.internal.appStart.hybridSDKMode) + XCTAssertTrue(SentrySDK.internal.performance.framesTrackingHybridSDKMode) } } @@ -89,10 +90,10 @@ final class RNSentryStartTests: XCTestCase { for startMethod in testCases { try startMethod() - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options - XCTAssertFalse(PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode) - XCTAssertFalse(PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode) + XCTAssertFalse(SentrySDK.internal.appStart.hybridSDKMode) + XCTAssertFalse(SentrySDK.internal.performance.framesTrackingHybridSDKMode) } } @@ -113,7 +114,7 @@ final class RNSentryStartTests: XCTestCase { for startMethod in testCases { try startMethod() - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options let actualEvent = actualOptions.beforeSend!(createUnhandledJsExceptionEvent()) @@ -138,7 +139,7 @@ final class RNSentryStartTests: XCTestCase { for startMethod in testCases { try startMethod() - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options let actualEvent = actualOptions.beforeSend!(createNativeEvent()) @@ -160,7 +161,7 @@ final class RNSentryStartTests: XCTestCase { } } - PrivateSentrySDKOnly.options.beforeSend!(genericEvent()) + SentrySDK.internal.options.beforeSend!(genericEvent()) XCTAssertTrue(executed) } @@ -207,7 +208,7 @@ final class RNSentryStartTests: XCTestCase { ] ]) - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options XCTAssertTrue(actualOptions.attachScreenshot) XCTAssertFalse(actualOptions.screenshot.maskAllText) XCTAssertTrue(actualOptions.screenshot.maskAllImages) @@ -219,7 +220,7 @@ final class RNSentryStartTests: XCTestCase { "attachScreenshot": true ]) - let actualOptions = PrivateSentrySDKOnly.options + let actualOptions = SentrySDK.internal.options XCTAssertTrue(actualOptions.attachScreenshot) XCTAssertTrue(actualOptions.screenshot.maskAllText) XCTAssertTrue(actualOptions.screenshot.maskAllImages) @@ -258,8 +259,8 @@ final class RNSentryStartTests: XCTestCase { var actualEvent: Event? // This is the closest to the sent event we can get using the actual Sentry start method - let originalBeforeSend = PrivateSentrySDKOnly.options.beforeSend - PrivateSentrySDKOnly.options.beforeSend = { event in + let originalBeforeSend = SentrySDK.internal.options.beforeSend + SentrySDK.internal.options.beforeSend = { event in if let originalBeforeSend = originalBeforeSend { let processedEvent = originalBeforeSend(event) actualEvent = processedEvent diff --git a/packages/core/ios/AGENTS.md b/packages/core/ios/AGENTS.md index b13a4c617a..04edda1bad 100644 --- a/packages/core/ios/AGENTS.md +++ b/packages/core/ios/AGENTS.md @@ -56,5 +56,15 @@ RCT_EXPORT_METHOD(nativeOperation:(NSString *)param 2. Edit `RNSentry.podspec` to remove version constraint 3. Add local pod to sample's Podfile: ```ruby - pod 'Sentry/HybridSDK', :path => '../../../../sentry-cocoa' + pod 'Sentry', :path => '../../../../sentry-cocoa' ``` + +## Internal API access (`SentrySDK.internal`) + +RNSentry consumes sentry-cocoa's hybrid-SDK surface (`SentrySDK.internal.*`) +through a Swift bridge in `RNSentryInternal.swift`. The bridge imports Sentry +with `@_spi(Private)` because several sub-APIs (`performance.currentScreenFrames`, +`replay.configure`, `envelope.{store,capture,deserialize}`) are SPI-gated. +`.m`/`.mm` callers import the auto-generated `RNSentry-Swift.h` and route +through `[RNSentryInternal …]` instead of touching `PrivateSentrySDKOnly` +(deprecated since cocoa 9.19.0 and slated for removal in the next major). diff --git a/packages/core/ios/RNSentry+fetchNativeStack.m b/packages/core/ios/RNSentry+fetchNativeStack.m index 196e726ddf..1298cf10c3 100644 --- a/packages/core/ios/RNSentry+fetchNativeStack.m +++ b/packages/core/ios/RNSentry+fetchNativeStack.m @@ -2,7 +2,6 @@ #import "RNSentryBreadcrumb.h" #import "RNSentryHexFormatter.h" #import "RNSentryId.h" -#import @import Sentry; // This method was moved to a new category so we can use `@import Sentry` to use Sentry's Swift diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 6cc9e946f4..1207a09a93 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -16,9 +16,13 @@ # define SENTRY_TARGET_PROFILING_SUPPORTED 0 #endif +#if __has_include() +# import +#else +# import "RNSentry-Swift.h" +#endif #import "RNSentryBreadcrumb.h" #import "RNSentryId.h" -#import #import #import #import @@ -403,10 +407,8 @@ - (void)handleShakeDetected RCT_EXPORT_METHOD( fetchNativeSdkInfo : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { - resolve(@ { - @"name" : PrivateSentrySDKOnly.getSdkName, - @"version" : PrivateSentrySDKOnly.getSdkVersionString - }); + resolve( + @ { @"name" : RNSentryInternal.sdkName, @"version" : RNSentryInternal.sdkVersionString }); } RCT_EXPORT_METHOD( @@ -458,7 +460,7 @@ - (void)handleShakeDetected contexts[@"release"] = releaseName; } // Merge extra context - NSDictionary *extraContext = [PrivateSentrySDKOnly getExtraContext]; + NSDictionary *extraContext = [RNSentryInternal extraContext]; if (extraContext) { NSDictionary *extraDevice = extraContext[@"device"]; @@ -496,8 +498,7 @@ - (void)handleShakeDetected NSDictionary *user = [serializedScope valueForKey:@"user"]; if (user == nil) { - [serializedScope setValue:@ { @"id" : PrivateSentrySDKOnly.installationID } - forKey:@"user"]; + [serializedScope setValue:@ { @"id" : RNSentryInternal.installationID } forKey:@"user"]; } if ([SentrySDKWrapper debug]) { @@ -510,7 +511,7 @@ - (void)handleShakeDetected } }]; - NSDictionary *extraContext = [PrivateSentrySDKOnly getExtraContext]; + NSDictionary *extraContext = [RNSentryInternal extraContext]; NSMutableDictionary *> *contexts = [serializedScope[@"context"] mutableCopy]; @@ -547,8 +548,7 @@ - (void)handleShakeDetected fetchNativeAppStart : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { #if SENTRY_HAS_UIKIT - NSDictionary *measurements = - [PrivateSentrySDKOnly appStartMeasurementWithSpans]; + NSDictionary *measurements = [RNSentryInternal appStartMeasurementWithSpans]; if (measurements == nil) { resolve(nil); return; @@ -574,7 +574,7 @@ - (void)handleShakeDetected { #if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - if (PrivateSentrySDKOnly.isFramesTrackingRunning) { + if (RNSentryInternal.isFramesTrackingRunning) { if (![SentryScreenFramesWrapper canTrackFrames]) { resolve(nil); return; @@ -626,20 +626,20 @@ - (void)handleShakeDetected { NSData *data = [[NSData alloc] initWithBase64EncodedString:rawBytes options:0]; - SentryEnvelope *envelope = [PrivateSentrySDKOnly envelopeWithData:data]; + SentryEnvelope *envelope = [RNSentryInternal envelopeFromData:data]; if (envelope == nil) { reject(@"SentryReactNative", @"Failed to parse envelope from byte array.", nil); return; } #if DEBUG - [PrivateSentrySDKOnly captureEnvelope:envelope]; + [RNSentryInternal capture:envelope]; #else if ([[options objectForKey:@"hardCrashed"] boolValue]) { // Storing to disk happens asynchronously with captureEnvelope - [PrivateSentrySDKOnly storeEnvelope:envelope]; + [RNSentryInternal store:envelope]; } else { - [PrivateSentrySDKOnly captureEnvelope:envelope]; + [RNSentryInternal capture:envelope]; } #endif resolve(@YES); @@ -649,7 +649,7 @@ - (void)handleShakeDetected captureScreenshot : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { #if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - NSArray *rawScreenshots = [PrivateSentrySDKOnly captureScreenshots]; + NSArray *rawScreenshots = [RNSentryInternal captureScreenshots]; NSMutableArray *screenshotsArray = [NSMutableArray arrayWithCapacity:[rawScreenshots count]]; int counter = 1; @@ -682,7 +682,13 @@ - (void)handleShakeDetected fetchViewHierarchy : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { #if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - NSData *rawViewHierarchy = [PrivateSentrySDKOnly captureViewHierarchy]; + NSData *rawViewHierarchy = [RNSentryInternal captureViewHierarchy]; + if (rawViewHierarchy == nil) { + // Propagate the capture failure to JS instead of a truthy `[]`, which + // would be treated as a successful (empty) attachment. + resolve(nil); + return; + } NSMutableArray *viewHierarchy = [NSMutableArray arrayWithCapacity:rawViewHierarchy.length]; const char *bytes = (char *)[rawViewHierarchy bytes]; @@ -733,7 +739,7 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys #if SENTRY_HAS_UIKIT NSString *_Nullable screen = [RNSentryBreadcrumb getCurrentScreenFrom:breadcrumb]; if (screen != nil) { - [PrivateSentrySDKOnly setCurrentScreen:screen]; + [RNSentryInternal setCurrentScreen:screen]; } #endif // SENTRY_HAS_UIKIT } @@ -816,52 +822,19 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys RCT_EXPORT_METHOD(resumeAppHangTracking) { [SentrySDKWrapper resumeAppHangTracking]; } -/** - * Calls captureReplay on the native replay integration and returns - * the BOOL result indicating whether the capture succeeded. - * - * PrivateSentrySDKOnly.captureReplay is void and discards the result, - * so we call the integration directly to get the success status. - * This prevents returning a stale buffer-mode replay ID when the - * capture actually failed (e.g., replay not running). - * - * Falls back to the old void captureReplay if the integration - * cannot be accessed directly (e.g., future Cocoa SDK changes). - * - * See https://github.com/getsentry/sentry-react-native/issues/5074 - */ +// Calls `SentrySDK.internal.replay.capture()` via the Swift bridge and returns +// the BOOL result. The `@try`/`@catch` is retained as cheap insurance for one +// release cycle — see getsentry/sentry-react-native#5074 for the historical +// fault path that motivated defensive handling here. + (BOOL)captureReplayWithReturnValue { #if SENTRY_TARGET_REPLAY_SUPPORTED @try { - if ([PrivateSentrySDKOnly respondsToSelector:@selector(getReplayIntegration)]) { -# pragma clang diagnostic push -# pragma clang diagnostic ignored "-Warc-performSelector-leaks" - id replayIntegration = - [PrivateSentrySDKOnly performSelector:@selector(getReplayIntegration)]; -# pragma clang diagnostic pop - if (replayIntegration && - [replayIntegration respondsToSelector:@selector(captureReplay)]) { - typedef BOOL (*CaptureReplayIMP)(id, SEL); - CaptureReplayIMP captureFunc = (CaptureReplayIMP) - [replayIntegration methodForSelector:@selector(captureReplay)]; - return captureFunc(replayIntegration, @selector(captureReplay)); - } - } - } @catch (NSException *exception) { - NSLog(@"[RNSentry] Failed to call captureReplay on integration: %@", exception); - } - // Fallback: call the void method and assume success if a replay ID exists. - // This preserves the old behavior when the integration isn't directly accessible. - // clang-format off - @try { - [PrivateSentrySDKOnly captureReplay]; - return [PrivateSentrySDKOnly getReplayId] != nil; + return [RNSentryInternal captureReplay]; } @catch (NSException *exception) { - NSLog(@"[RNSentry] Failed to call captureReplay fallback: %@", exception); + NSLog(@"[RNSentry] Failed to call captureReplay: %@", exception); return NO; } - // clang-format on #else return NO; #endif @@ -873,7 +846,7 @@ + (BOOL)captureReplayWithReturnValue #if SENTRY_TARGET_REPLAY_SUPPORTED BOOL captured = [RNSentry captureReplayWithReturnValue]; if (captured) { - resolve([PrivateSentrySDKOnly getReplayId]); + resolve([RNSentryInternal replayId]); } else { resolve(nil); } @@ -963,7 +936,7 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, getCurrentReplayId) { #if SENTRY_TARGET_REPLAY_SUPPORTED - return [PrivateSentrySDKOnly getReplayId]; + return [RNSentryInternal replayId]; #else return nil; #endif @@ -989,8 +962,7 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path if (nativeProfileTraceId == nil && nativeProfileStartTime == 0 && platformProfilers) { # if SENTRY_TARGET_PROFILING_SUPPORTED nativeProfileTraceId = [RNSentryId newId]; - nativeProfileStartTime = - [PrivateSentrySDKOnly startProfilerForTrace:nativeProfileTraceId]; + nativeProfileStartTime = [RNSentryInternal startProfilerForTrace:nativeProfileTraceId]; # endif } else { if (!platformProfilers) { @@ -1004,7 +976,7 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path } catch (const std::exception &ex) { if (nativeProfileTraceId != nil) { # if SENTRY_TARGET_PROFILING_SUPPORTED - [PrivateSentrySDKOnly discardProfilerForTrace:nativeProfileTraceId]; + [RNSentryInternal discardProfilerForTrace:nativeProfileTraceId]; # endif nativeProfileTraceId = nil; } @@ -1016,7 +988,7 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path } catch (...) { if (nativeProfileTraceId != nil) { # if SENTRY_TARGET_PROFILING_SUPPORTED - [PrivateSentrySDKOnly discardProfilerForTrace:nativeProfileTraceId]; + [RNSentryInternal discardProfilerForTrace:nativeProfileTraceId]; # endif nativeProfileTraceId = nil; } @@ -1036,9 +1008,9 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path if (nativeProfileTraceId != nil && nativeProfileStartTime != 0) { # if SENTRY_TARGET_PROFILING_SUPPORTED uint64_t nativeProfileStopTime = clock_gettime_nsec_np(CLOCK_UPTIME_RAW); - nativeProfile = [PrivateSentrySDKOnly collectProfileBetween:nativeProfileStartTime - and:nativeProfileStopTime - forTrace:nativeProfileTraceId]; + nativeProfile = [RNSentryInternal collectProfileBetween:nativeProfileStartTime + and:nativeProfileStopTime + forTrace:nativeProfileTraceId]; # endif } // Cleanup native profiles @@ -1093,7 +1065,7 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path } catch (const std::exception &ex) { if (nativeProfileTraceId != nil) { # if SENTRY_TARGET_PROFILING_SUPPORTED - [PrivateSentrySDKOnly discardProfilerForTrace:nativeProfileTraceId]; + [RNSentryInternal discardProfilerForTrace:nativeProfileTraceId]; # endif nativeProfileTraceId = nil; } @@ -1105,7 +1077,7 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path } catch (...) { if (nativeProfileTraceId != nil) { # if SENTRY_TARGET_PROFILING_SUPPORTED - [PrivateSentrySDKOnly discardProfilerForTrace:nativeProfileTraceId]; + [RNSentryInternal discardProfilerForTrace:nativeProfileTraceId]; # endif nativeProfileTraceId = nil; } diff --git a/packages/core/ios/RNSentryInternal.swift b/packages/core/ios/RNSentryInternal.swift new file mode 100644 index 0000000000..94859b44d6 --- /dev/null +++ b/packages/core/ios/RNSentryInternal.swift @@ -0,0 +1,259 @@ +import Foundation +@_spi(Private) import Sentry + +/// Thin Objective-C-visible bridge over `SentrySDK.internal.*`. +/// +/// React Native's iOS code is mostly `.m`/`.mm`, but the new hybrid-SDK API +/// (`SentrySDK.internal`) is Swift-only and several methods are gated by +/// `@_spi(Private)`. This file imports with the SPI name and exposes a flat +/// `@objc` surface that mirrors the call sites we use today. +/// +/// Platform gating mirrors each `SentryInternal*Api` in sentry-cocoa +/// (`Sources/Swift/HybridSDK/`). Where a sub-API is excluded on a platform, +/// stubs return safe defaults so `.m`/`.mm` callers still resolve symbols. +@_spi(Private) @objc public final class RNSentryInternal: NSObject { + + // MARK: - SDK metadata + + @_spi(Private) @objc public static var sdkName: String { SentrySDK.internal.sdk.name } + + @_spi(Private) @objc public static var sdkVersionString: String { SentrySDK.internal.sdk.versionString } + + @_spi(Private) @objc public static func setSdkName(_ name: String, version: String) { + SentrySDK.internal.sdk.setName(name, version: version) + } + + @_spi(Private) @objc public static func addSdkPackage(_ name: String, version: String) { + SentrySDK.internal.sdk.addPackage(name: name, version: version) + } + + @_spi(Private) @objc public static var extraContext: [String: Any] { + SentrySDK.internal.sdk.extraContext + } + + @_spi(Private) @objc public static var installationID: String { + SentrySDK.internal.sdk.installationID + } + + // MARK: - Options + + @_spi(Private) @objc public static var options: Options { SentrySDK.internal.options } + + @_spi(Private) @objc public static func options(fromDictionary dict: [String: Any]) throws -> Options { + try SentrySDK.internal.options(fromDictionary: dict) + } + + // MARK: - App start + + @_spi(Private) @objc public static var appStartMeasurementHybridSDKMode: Bool { + get { SentrySDK.internal.appStart.hybridSDKMode } + set { SentrySDK.internal.appStart.hybridSDKMode = newValue } + } + + @_spi(Private) @objc public static var appStartMeasurementWithSpans: [String: Any]? { + SentrySDK.internal.appStart.measurementWithSpans + } + + // MARK: - Performance / frames + + #if os(iOS) || os(tvOS) || os(visionOS) + @_spi(Private) @objc public static var framesTrackingMeasurementHybridSDKMode: Bool { + get { SentrySDK.internal.performance.framesTrackingHybridSDKMode } + set { SentrySDK.internal.performance.framesTrackingHybridSDKMode = newValue } + } + + @_spi(Private) @objc public static var isFramesTrackingRunning: Bool { + SentrySDK.internal.performance.isFramesTrackingRunning + } + + @_spi(Private) @objc public static var currentScreenFrames: SentryScreenFrames? { + SentrySDK.internal.performance.currentScreenFrames + } + #else + @_spi(Private) @objc public static var framesTrackingMeasurementHybridSDKMode: Bool { + get { false } + set {} + } + + @_spi(Private) @objc public static var isFramesTrackingRunning: Bool { false } + + // `currentScreenFrames` is intentionally NOT declared on non-UIKit + // platforms: sentry-cocoa does not compile `SentryScreenFrames` there, + // so referencing it as a return type would fail to resolve. Every ObjC + // caller (`SentryScreenFramesWrapper.m`) is already gated to + // `TARGET_OS_IPHONE || TARGET_OS_MACCATALYST`, so no consumer needs the + // stub on macOS/watchOS. + #endif + + // MARK: - Envelope + + // Accepts `Data?` (nil-safe) rather than `Data` so the ObjC bridge boundary + // doesn't force-unwrap a nil `NSData*` from a failed base64 decode — that + // would crash before we ever get a chance to check the result. Matches the + // nil-tolerant behaviour of the deprecated `PrivateSentrySDKOnly.envelopeWithData:`. + @_spi(Private) @objc public static func envelope(fromData data: Data?) -> SentryEnvelope? { + guard let data = data else { return nil } + return SentrySDK.internal.envelope.deserialize(from: data) + } + + @_spi(Private) @objc public static func capture(_ envelope: SentryEnvelope) { + SentrySDK.internal.envelope.capture(envelope) + } + + @_spi(Private) @objc public static func store(_ envelope: SentryEnvelope) { + SentrySDK.internal.envelope.store(envelope) + } + + // MARK: - Screenshot / view hierarchy / screen + + // sentry-cocoa's `SentryInternalScreen/Screenshot/ViewHierarchyApi` are all + // gated to `(os(iOS) || os(tvOS)) && !SENTRY_NO_UI_FRAMEWORK`. On visionOS + // the new hybrid-SDK surface is intentionally absent, but the same + // functionality still lives on `PrivateSentrySDKOnly` (gated by + // `SENTRY_HAS_UIKIT`, which covers visionOS). Route the visionOS bridge + // through the deprecated SPI so we preserve pre-migration behaviour and + // keep this PR non-breaking. Remove the fallback once sentry-cocoa + // exposes these APIs on visionOS in the hybrid surface — or once cocoa + // drops `PrivateSentrySDKOnly` in a future major and forces our hand. + #if os(iOS) || os(tvOS) + @_spi(Private) @objc public static var captureScreenshots: [Data]? { + SentrySDK.internal.screenshot.capture() + } + + @_spi(Private) @objc public static var captureViewHierarchy: Data? { + SentrySDK.internal.viewHierarchy.capture() + } + + @_spi(Private) @objc public static func setCurrentScreen(_ screenName: String?) { + SentrySDK.internal.screen.setCurrent(screenName) + } + #elseif os(visionOS) + @_spi(Private) @objc public static var captureScreenshots: [Data]? { + PrivateSentrySDKOnly.captureScreenshots() + } + + @_spi(Private) @objc public static var captureViewHierarchy: Data? { + PrivateSentrySDKOnly.captureViewHierarchy() + } + + @_spi(Private) @objc public static func setCurrentScreen(_ screenName: String?) { + PrivateSentrySDKOnly.setCurrentScreen(screenName) + } + #else + @_spi(Private) @objc public static var captureScreenshots: [Data]? { nil } + @_spi(Private) @objc public static var captureViewHierarchy: Data? { nil } + @_spi(Private) @objc public static func setCurrentScreen(_ screenName: String?) {} + #endif + + // MARK: - Replay + + #if os(iOS) || os(tvOS) + @_spi(Private) @objc public static func captureReplay() -> Bool { + SentrySDK.internal.replay.capture() + } + + @_spi(Private) @objc public static var replayId: String? { + SentrySDK.internal.replay.replayId + } + + @_spi(Private) @objc public static func setReplayRedactContainerClass(_ containerClass: AnyClass) { + SentrySDK.internal.replay.setRedactContainerClass(containerClass) + } + + @_spi(Private) @objc public static func setReplayIgnoreContainerClass(_ containerClass: AnyClass) { + SentrySDK.internal.replay.setIgnoreContainerClass(containerClass) + } + + @_spi(Private) @objc public static func configureReplay( + breadcrumbConverter: SentryReplayBreadcrumbConverter + ) { + SentrySDK.internal.replay.configure( + breadcrumbConverter: breadcrumbConverter, + screenshotProvider: nil + ) + } + #else + @_spi(Private) @objc public static func captureReplay() -> Bool { false } + @_spi(Private) @objc public static var replayId: String? { nil } + @_spi(Private) @objc public static func setReplayRedactContainerClass(_ containerClass: AnyClass) {} + @_spi(Private) @objc public static func setReplayIgnoreContainerClass(_ containerClass: AnyClass) {} + @_spi(Private) @objc public static func configureReplay( + breadcrumbConverter: SentryReplayBreadcrumbConverter + ) {} + #endif + + // MARK: - Swizzle + + // `SentryInternalSwizzleApi` in sentry-cocoa has no platform gating. + // We enable the wrapper on every UIKit-capable platform (iOS/tvOS/visionOS + // — matching `SENTRY_UIKIT_AVAILABLE`, which is what `SENTRY_HAS_UIKIT` + // resolves to in the RNSentry.mm caller). `os(iOS)` in Swift already + // covers Mac Catalyst, so no separate `targetEnvironment(macCatalyst)` is + // needed. + #if os(iOS) || os(tvOS) || os(visionOS) + /// Stable identity for the `RNSScreen.viewDidAppear:` swizzle so the underlying + /// `oncePerClass` bookkeeping in sentry-cocoa can dedupe re-entries. + private static var rnsScreenViewDidAppearKey: UInt8 = 0 + + /// Swizzles `-[RNSScreen viewDidAppear:]`, invoking `hook` before the original + /// implementation on every call. No-op when RNSScreen is unavailable. + @_spi(Private) @objc public static func swizzleRNSScreenViewDidAppear(hook: @escaping () -> Void) { + guard let cls = NSClassFromString("RNSScreen") else { return } + let selector = NSSelectorFromString("viewDidAppear:") + + // `withUnsafePointer(to:)` scopes the pointer's validity to the closure + // body. Perform the entire swizzle call inside so we never rely on the + // pointer surviving beyond the closure. The backing storage is a + // `static var`, so the address itself stays stable across calls — + // sentry-cocoa's `oncePerClass` bookkeeping continues to dedupe. + withUnsafePointer(to: &rnsScreenViewDidAppearKey) { keyPtr in + _ = SentrySDK.internal.swizzle.instanceMethod( + selector, + in: cls, + mode: .oncePerClass, + key: UnsafeRawPointer(keyPtr), + factory: { getOriginal in + let block: @convention(block) (AnyObject, ObjCBool) -> Void = { receiver, animated in + hook() + typealias OriginalIMP = @convention(c) (AnyObject, Selector, ObjCBool) -> Void + let original = unsafeBitCast(getOriginal(), to: OriginalIMP.self) + original(receiver, selector, animated) + } + return block as AnyObject + } + ) + } + } + #else + @_spi(Private) @objc public static func swizzleRNSScreenViewDidAppear(hook: @escaping () -> Void) {} + #endif + + // MARK: - Profiling + + #if !(os(watchOS) || os(tvOS) || os(visionOS)) + @_spi(Private) @objc public static func startProfiler(forTrace traceId: SentryId) -> UInt64 { + SentrySDK.internal.profiling.start(for: traceId) + } + + @_spi(Private) @objc(collectProfileBetween:and:forTrace:) + public static func collectProfile( + between startTime: UInt64, + and endTime: UInt64, + forTrace traceId: SentryId + ) -> [String: Any]? { + SentrySDK.internal.profiling.collect(between: startTime, and: endTime, for: traceId) + } + + @_spi(Private) @objc public static func discardProfiler(forTrace traceId: SentryId) { + SentrySDK.internal.profiling.discard(for: traceId) + } + #else + @_spi(Private) @objc public static func startProfiler(forTrace traceId: SentryId) -> UInt64 { 0 } + @_spi(Private) @objc public static func collectProfile( + between startTime: UInt64, + and endTime: UInt64, + forTrace traceId: SentryId + ) -> [String: Any]? { nil } + @_spi(Private) @objc public static func discardProfiler(forTrace traceId: SentryId) {} + #endif +} diff --git a/packages/core/ios/RNSentryRNSScreen.m b/packages/core/ios/RNSentryRNSScreen.m index 91bbb58c08..52c3658fba 100644 --- a/packages/core/ios/RNSentryRNSScreen.m +++ b/packages/core/ios/RNSentryRNSScreen.m @@ -2,31 +2,21 @@ #if SENTRY_HAS_UIKIT -# import "RNSentryDependencyContainer.h" -# import "RNSentryFramesTrackerListener.h" -# if __has_include() -# import +# if __has_include() +# import # else -# import "SentrySwizzle.h" +# import "RNSentry-Swift.h" # endif -@import Sentry; +# import "RNSentryDependencyContainer.h" +# import "RNSentryFramesTrackerListener.h" @implementation RNSentryRNSScreen + (void)swizzleViewDidAppear { - Class rnsscreenclass = NSClassFromString(@"RNSScreen"); - if (rnsscreenclass == nil) { - return; - } - - SEL selector = NSSelectorFromString(@"viewDidAppear:"); - SentrySwizzleInstanceMethod(rnsscreenclass, selector, SentrySWReturnType(void), - SentrySWArguments(BOOL animated), SentrySWReplacement({ - [[[RNSentryDependencyContainer sharedInstance] framesTrackerListener] startListening]; - SentrySWCallOriginal(animated); - }), - SentrySwizzleModeOncePerClass, (void *)selector); + [RNSentryInternal swizzleRNSScreenViewDidAppearWithHook:^{ + [[[RNSentryDependencyContainer sharedInstance] framesTrackerListener] startListening]; + }]; } @end diff --git a/packages/core/ios/RNSentryReplay.mm b/packages/core/ios/RNSentryReplay.mm index 2c3273687e..60c4cfe93f 100644 --- a/packages/core/ios/RNSentryReplay.mm +++ b/packages/core/ios/RNSentryReplay.mm @@ -1,10 +1,14 @@ #import "RNSentryReplay.h" +#if __has_include() +# import +#else +# import "RNSentry-Swift.h" +#endif #import "RNSentryReplayBreadcrumbConverterHelper.h" #import "RNSentryReplayQuality.h" #import "RNSentryVersion.h" #import "Replay/RNSentryReplayMask.h" #import "Replay/RNSentryReplayUnmask.h" -#import #if SENTRY_TARGET_REPLAY_SUPPORTED @@ -76,8 +80,8 @@ + (void)postInit { // 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]]; + [RNSentryInternal setReplayRedactContainerClass:[RNSentryReplay getMaskClass]]; + [RNSentryInternal setReplayIgnoreContainerClass:[RNSentryReplay getUnmaskClass]]; [RNSentryReplayBreadcrumbConverterHelper configureSessionReplayWithConverter]; } diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m index ef3da7ec38..08ace7cc7a 100644 --- a/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m @@ -1,6 +1,11 @@ #import "RNSentryReplayBreadcrumbConverterHelper.h" #if SENTRY_TARGET_REPLAY_SUPPORTED +# if __has_include() +# import +# else +# import "RNSentry-Swift.h" +# endif # import "RNSentryReplayBreadcrumbConverter.h" @implementation RNSentryReplayBreadcrumbConverterHelper @@ -9,7 +14,7 @@ + (void)configureSessionReplayWithConverter { RNSentryReplayBreadcrumbConverter *breadcrumbConverter = [[RNSentryReplayBreadcrumbConverter alloc] init]; - [PrivateSentrySDKOnly configureSessionReplayWith:breadcrumbConverter screenshotProvider:nil]; + [RNSentryInternal configureReplayWithBreadcrumbConverter:breadcrumbConverter]; } @end diff --git a/packages/core/ios/RNSentrySDK.m b/packages/core/ios/RNSentrySDK.m index 0f38cf6c7b..1f9de9fe52 100644 --- a/packages/core/ios/RNSentrySDK.m +++ b/packages/core/ios/RNSentrySDK.m @@ -1,6 +1,10 @@ #import "RNSentrySDK.h" +#if __has_include() +# import +#else +# import "RNSentry-Swift.h" +#endif #import "RNSentryStart.h" -#import #import static NSString *SENTRY_OPTIONS_RESOURCE_NAME = @"sentry.options"; @@ -60,7 +64,7 @@ + (void)start:(NSString *)path configureOptions:(void (^)(SentryOptions *options if (options == nil) { // Fallback in case that options file could not be parsed. NSError *fallbackError = nil; - options = [PrivateSentrySDKOnly optionsWithDictionary:@{ } didFailWithError:&fallbackError]; + options = [RNSentryInternal optionsFromDictionary:@{ } error:&fallbackError]; if (fallbackError != nil) { NSLog(@"[RNSentry] Failed to create fallback options with error: %@", fallbackError.localizedDescription); diff --git a/packages/core/ios/RNSentryStart.m b/packages/core/ios/RNSentryStart.m index fa463f116e..ce365badea 100644 --- a/packages/core/ios/RNSentryStart.m +++ b/packages/core/ios/RNSentryStart.m @@ -3,7 +3,11 @@ #import "RNSentryReplay.h" #import "RNSentryVersion.h" -#import +#if __has_include() +# import +#else +# import "RNSentry-Swift.h" +#endif #import @import Sentry; @@ -31,14 +35,14 @@ + (void)startWithOptions:(SentryOptions *)options NS_SWIFT_NAME(start(options:)) + (void)startWithOptions:(SentryOptions *)options jsSdkVersion:(NSString *_Nullable)jsSdkVersion { - NSString *sdkVersion = [PrivateSentrySDKOnly getSdkVersionString]; - [PrivateSentrySDKOnly setSdkName:NATIVE_SDK_NAME andVersionString:sdkVersion]; - [PrivateSentrySDKOnly addSdkPackage:REACT_NATIVE_SDK_PACKAGE_NAME - version:REACT_NATIVE_SDK_PACKAGE_VERSION]; + NSString *sdkVersion = [RNSentryInternal sdkVersionString]; + [RNSentryInternal setSdkName:NATIVE_SDK_NAME version:sdkVersion]; + [RNSentryInternal addSdkPackage:REACT_NATIVE_SDK_PACKAGE_NAME + version:REACT_NATIVE_SDK_PACKAGE_VERSION]; if (jsSdkVersion != nil && ![jsSdkVersion isEqualToString:REACT_NATIVE_SDK_PACKAGE_VERSION]) { NSString *otaPackageName = [REACT_NATIVE_SDK_PACKAGE_NAME stringByAppendingString:@":ota"]; - [PrivateSentrySDKOnly addSdkPackage:otaPackageName version:jsSdkVersion]; + [RNSentryInternal addSdkPackage:otaPackageName version:jsSdkVersion]; } [SentrySDK startWithOptions:options]; @@ -62,8 +66,8 @@ + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) [RNSentryReplay updateOptions:mutableOptions]; #endif - SentryOptions *sentryOptions = [PrivateSentrySDKOnly optionsWithDictionary:mutableOptions - didFailWithError:errorPointer]; + SentryOptions *sentryOptions = [RNSentryInternal optionsFromDictionary:mutableOptions + error:errorPointer]; if (*errorPointer != nil) { return nil; } @@ -237,12 +241,11 @@ + (void)updateWithReactFinals:(SentryOptions *)options // App Start Hybrid mode doesn't wait for didFinishLaunchNotification and the // didBecomeVisibleNotification as they will be missed when auto initializing from JS // App Start measurements are created right after the tracking starts - PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = options.enableAutoPerformanceTracing; + RNSentryInternal.appStartMeasurementHybridSDKMode = options.enableAutoPerformanceTracing; #if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST // Frames Tracking Hybrid Mode ensures tracking // is enabled without tracing enabled in the native SDK - PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode - = options.enableAutoPerformanceTracing; + RNSentryInternal.framesTrackingMeasurementHybridSDKMode = options.enableAutoPerformanceTracing; #endif } @@ -301,8 +304,8 @@ + (void)postDidBecomeActiveNotification // If the app is active/in foreground, and we have not sent the SentryHybridSdkDidBecomeActive // notification, send it. if (appIsActive && !sentHybridSdkDidBecomeActive - && (PrivateSentrySDKOnly.options.enableAutoSessionTracking - || PrivateSentrySDKOnly.options.enableWatchdogTerminationTracking)) { + && (RNSentryInternal.options.enableAutoSessionTracking + || RNSentryInternal.options.enableWatchdogTerminationTracking)) { // Updates Native App State Manager // https://github.com/getsentry/sentry-cocoa/blob/888a145b144b8077e03151a886520f332e47e297/Sources/Sentry/SentryAppStateManager.m#L136 // Triggers Session Tracker diff --git a/packages/core/ios/RNSentryTimeToDisplay.h b/packages/core/ios/RNSentryTimeToDisplay.h index a29a8e1cd3..06e5132df8 100644 --- a/packages/core/ios/RNSentryTimeToDisplay.h +++ b/packages/core/ios/RNSentryTimeToDisplay.h @@ -1,6 +1,12 @@ #import -static const int TIME_TO_DISPLAY_ENTRIES_MAX_SIZE = 50; +// Declared `extern` (not `static`) so it gets external linkage and can be +// referenced from Swift after RNSentry became a Swift-containing pod +// (see `RNSentryInternal.swift`). With `static const` the constant has +// internal linkage per translation unit and Swift's module import fails +// to resolve it, breaking `RNSentryTimeToDisplayTests.swift` with +// `Undefined symbol: _TIME_TO_DISPLAY_ENTRIES_MAX_SIZE` at link time. +extern const int TIME_TO_DISPLAY_ENTRIES_MAX_SIZE; @interface RNSentryTimeToDisplay : NSObject diff --git a/packages/core/ios/RNSentryTimeToDisplay.m b/packages/core/ios/RNSentryTimeToDisplay.m index 9d31f41a25..d46dc23bdc 100644 --- a/packages/core/ios/RNSentryTimeToDisplay.m +++ b/packages/core/ios/RNSentryTimeToDisplay.m @@ -2,6 +2,8 @@ #import #import +const int TIME_TO_DISPLAY_ENTRIES_MAX_SIZE = 50; + // All static state below is accessed from the main thread (CADisplayLink, UI) and from the // React Native bridge / JS thread (setActiveSpanId, pop). Synchronize every access. @implementation RNSentryTimeToDisplay { diff --git a/packages/core/ios/SentrySDKWrapper.m b/packages/core/ios/SentrySDKWrapper.m index 9aa5a62493..881d3bc69b 100644 --- a/packages/core/ios/SentrySDKWrapper.m +++ b/packages/core/ios/SentrySDKWrapper.m @@ -1,4 +1,9 @@ #import "SentrySDKWrapper.h" +#if __has_include() +# import +#else +# import "RNSentry-Swift.h" +#endif @import Sentry; @implementation SentrySDKWrapper @@ -35,12 +40,12 @@ + (void)configureScope:(void (^)(SentryScope *scope))callback + (BOOL)debug { - return PrivateSentrySDKOnly.options.debug; + return RNSentryInternal.options.debug; } + (NSString *)releaseName { - return PrivateSentrySDKOnly.options.releaseName; + return RNSentryInternal.options.releaseName; } @end diff --git a/packages/core/ios/SentryScreenFramesWrapper.m b/packages/core/ios/SentryScreenFramesWrapper.m index dd79c5a214..e7db337459 100644 --- a/packages/core/ios/SentryScreenFramesWrapper.m +++ b/packages/core/ios/SentryScreenFramesWrapper.m @@ -1,4 +1,9 @@ #import "SentryScreenFramesWrapper.h" +#if __has_include() +# import +#else +# import "RNSentry-Swift.h" +#endif @import Sentry; #if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST @@ -7,7 +12,7 @@ @implementation SentryScreenFramesWrapper + (BOOL)canTrackFrames { - return PrivateSentrySDKOnly.currentScreenFrames != nil; + return RNSentryInternal.currentScreenFrames != nil; } + (NSNumber *)totalFrames @@ -15,7 +20,7 @@ + (NSNumber *)totalFrames if (![self canTrackFrames]) { return nil; } - return [NSNumber numberWithLong:PrivateSentrySDKOnly.currentScreenFrames.total]; + return [NSNumber numberWithLong:RNSentryInternal.currentScreenFrames.total]; } + (NSNumber *)frozenFrames @@ -23,7 +28,7 @@ + (NSNumber *)frozenFrames if (![self canTrackFrames]) { return nil; } - return [NSNumber numberWithLong:PrivateSentrySDKOnly.currentScreenFrames.frozen]; + return [NSNumber numberWithLong:RNSentryInternal.currentScreenFrames.frozen]; } + (NSNumber *)slowFrames @@ -31,7 +36,7 @@ + (NSNumber *)slowFrames if (![self canTrackFrames]) { return nil; } - return [NSNumber numberWithLong:PrivateSentrySDKOnly.currentScreenFrames.slow]; + return [NSNumber numberWithLong:RNSentryInternal.currentScreenFrames.slow]; } + (NSNumber *)framesDelayForStartTimestamp:(double)startTimestampSeconds diff --git a/packages/core/scripts/sentry_utils.rb b/packages/core/scripts/sentry_utils.rb index 43a778c019..ee30f0ff7d 100644 --- a/packages/core/scripts/sentry_utils.rb +++ b/packages/core/scripts/sentry_utils.rb @@ -60,8 +60,8 @@ def is_new_hermes_runtime(rn_version) # `Sentry` module. `Sentry-Dynamic.xcframework` would ship the same # `Sentry.framework` inside but under a mismatched enclosing name, so # CocoaPods generates `-framework Sentry-Dynamic` and fails at link. - '9.19.1' => { - 'Sentry' => 'd6d545af17e49851cda2747b0f45cde78ce08ea37709dde5a956c6b4671224e8', + '9.24.0' => { + 'Sentry' => 'c530edd27b20f7c151e73d84a34ee03474e3d5ddab65ffe9d30366f80149668a', }, }.freeze