diff --git a/CHANGELOG_V10.md b/CHANGELOG_V10.md index 2f512ec69b..7839c98b73 100644 --- a/CHANGELOG_V10.md +++ b/CHANGELOG_V10.md @@ -4,6 +4,7 @@ ### Features +- Install KSCrash crash handler in `Sentry+KSCrash` with production-safe monitors matching SentryCrash's existing monitor set (#8469) - Add `options.dataCollection` to configure data collection behaviour (#8448) - Allows dictionary initialization for `options.dataCollection` (#8371) - Renamed `options.dataCollection.queryParams` to `options.dataCollection.urlQueryParams` (#8414) diff --git a/SentryTestUtils/Sources/ClearTestState.swift b/SentryTestUtils/Sources/ClearTestState.swift index e5fbc4e029..29c300b0b4 100644 --- a/SentryTestUtils/Sources/ClearTestState.swift +++ b/SentryTestUtils/Sources/ClearTestState.swift @@ -25,7 +25,6 @@ class TestCleanup: NSObject { SentrySDK.close() SentrySDKInternal.setCurrentHub(nil) SentrySDKInternal.lastRunStatusCalled = false - SentrySDKInternal.crashReporterInstalled = false SentrySDKInternal.fatalDetected = false SentrySDKInternal.startInvocations = 0 SentrySDKInternal.setDetectedStartUpCrash(false) diff --git a/Sources/Configuration/SentryKSCrash.xcconfig b/Sources/Configuration/SentryKSCrash.xcconfig index 089e1dc4db..06d2da4871 100644 --- a/Sources/Configuration/SentryKSCrash.xcconfig +++ b/Sources/Configuration/SentryKSCrash.xcconfig @@ -28,7 +28,15 @@ MACH_O_TYPE = mh_dylib // see https://github.com/getsentry/sentry-cocoa/pull/8426 for more context ARCHS[sdk=appletvos*] = $(ARCHS_STANDARD) ARCHS[sdk=watchos*] = $(ARCHS_STANDARD) -ARCHS[sdk=maccatalyst*] = $(ARCHS_STANDARD) + +// Mac Catalyst is not affected by iOSPackagesShouldBuildARM64e, so KSCrash SPM packages +// never produce arm64e slices for maccatalyst. Mac Catalyst also doesn't have +// it's own SDK name, we so we need to do some working around to use the one +// build setting that shows Mac Catalyst is being build for: IS_MACCATALYST. +// Key off it via nested macro substitution instead. +SENTRY_KSCRASH_ARM64E_IF_NOT_CATALYST_YES = +SENTRY_KSCRASH_ARM64E_IF_NOT_CATALYST_NO = arm64e +ARCHS[sdk=macosx*] = arm64 x86_64 $(SENTRY_KSCRASH_ARM64E_IF_NOT_CATALYST_$(IS_MACCATALYST)) // Both this target and the base Sentry target share PRODUCT_MODULE_NAME=Sentry. Xcode's // explicit module scanner can't tell them apart and emits a spurious missing-dependency diff --git a/Sources/Sentry/SentrySDKInternal.m b/Sources/Sentry/SentrySDKInternal.m index 2e95e35c73..c0b4d1efff 100644 --- a/Sources/Sentry/SentrySDKInternal.m +++ b/Sources/Sentry/SentrySDKInternal.m @@ -38,7 +38,6 @@ @implementation SentrySDKInternal static SentryHubInternal *_Nullable currentHub; static NSObject *currentHubLock; static BOOL lastRunStatusCalled; -static BOOL crashReporterInstalled; static BOOL fatalDetected; static SentryAppStartMeasurement *_Nullable sentrySDKappStartMeasurement; static NSObject *sentrySDKappStartMeasurementLock; @@ -143,12 +142,11 @@ + (void)setLastRunStatusCalled:(BOOL)value + (BOOL)crashReporterInstalled { - return crashReporterInstalled; -} - -+ (void)setCrashReporterInstalled:(BOOL)value -{ - crashReporterInstalled = value; +#if ENABLE_KSCRASH + return SentryDependencyContainer.sharedInstance.kscrashQuery.installed; +#else + return SentryDependencyContainer.sharedInstance.crashWrapper.installed; +#endif } + (BOOL)fatalDetected @@ -276,7 +274,7 @@ + (void)startWithOptions:(SentryOptions *)options // The .didCrash case is handled by SentryClient.prepareEvent when // a fatal event arrives. Here we report .didNotCrash if no // integration set fatalDetected during init. - if (crashReporterInstalled && !fatalDetected) { + if ([SentrySDKInternal crashReporterInstalled] && !fatalDetected) { lastRunStatusCalled = YES; if (nil != options.onLastRunStatusDetermined) { options.onLastRunStatusDetermined(SentryLastRunStatusDidNotCrash, nil); @@ -502,12 +500,19 @@ + (BOOL)crashedLastRun + (NSInteger)lastRunStatus { - if (!crashReporterInstalled) { + if (![SentrySDKInternal crashReporterInstalled]) { return SentryLastRunStatusUnknown; } + +#if ENABLE_KSCRASH + if (SentryDependencyContainer.sharedInstance.kscrashQuery.crashedLastLaunch) { + return SentryLastRunStatusDidCrash; + } +#else if (SentryDependencyContainer.sharedInstance.crashWrapper.crashedLastLaunch) { return SentryLastRunStatusDidCrash; } +#endif return SentryLastRunStatusDidNotCrash; } @@ -604,7 +609,6 @@ + (void)close [SentrySDKInternal setCurrentHub:nil]; - crashReporterInstalled = NO; fatalDetected = NO; lastRunStatusCalled = NO; diff --git a/Sources/Sentry/include/SentryCrash.h b/Sources/Sentry/include/SentryCrash.h index a5a0489cb6..b6e28062c6 100644 --- a/Sources/Sentry/include/SentryCrash.h +++ b/Sources/Sentry/include/SentryCrash.h @@ -189,6 +189,8 @@ SENTRY_NO_INIT /** If true, the application crashed on the previous launch. */ @property (nonatomic, readonly, assign) BOOL crashedLastLaunch; +@property (nonatomic, readonly) BOOL installed; + /** The total number of unsent reports. Note: This is an expensive operation. */ @property (nonatomic, readonly, assign) int reportCount; diff --git a/Sources/Sentry/include/SentrySDK+Private.h b/Sources/Sentry/include/SentrySDK+Private.h index 0aabe0f679..74b5e26f32 100644 --- a/Sources/Sentry/include/SentrySDK+Private.h +++ b/Sources/Sentry/include/SentrySDK+Private.h @@ -40,7 +40,7 @@ NS_ASSUME_NONNULL_BEGIN * from disk. This allows @c lastRunStatus to return a definitive answer instead of * @c SentryLastRunStatusUnknown. */ -@property (nonatomic, class) BOOL crashReporterInstalled; +@property (nonatomic, class, readonly) BOOL crashReporterInstalled; /** * Set to @c YES by any integration that detects a fatal event from the previous run diff --git a/Sources/SentryCrash/Recording/SentryCrash.m b/Sources/SentryCrash/Recording/SentryCrash.m index 9d33b20709..c0d6427071 100644 --- a/Sources/SentryCrash/Recording/SentryCrash.m +++ b/Sources/SentryCrash/Recording/SentryCrash.m @@ -290,6 +290,8 @@ - (BOOL)install object:nil]; #endif // SENTRY_HAS_NSEXTENSION + _installed = true; + return true; } @@ -300,6 +302,7 @@ - (void)uninstall self.monitoringFromUninstalledToRestore = YES; self.onCrash = NULL; sentrycrash_uninstall(); + _installed = false; #if SENTRY_HAS_UIKIT id notificationCenter diff --git a/Sources/Swift/Core/Integrations/Integrations.swift b/Sources/Swift/Core/Integrations/Integrations.swift index 5310b474d5..40ebe4869b 100644 --- a/Sources/Swift/Core/Integrations/Integrations.swift +++ b/Sources/Swift/Core/Integrations/Integrations.swift @@ -54,7 +54,7 @@ private struct AnyIntegration { #endif #if ENABLE_KSCRASH - integrations.append(.init(SentryKSCrashIntegration.self)) + integrations.append(.init(SentryKSCrash.Integration.self)) #else integrations.append(.init(SentryCrashIntegration.self)) #endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift new file mode 100644 index 0000000000..badf8f2e1b --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift @@ -0,0 +1,57 @@ +#if ENABLE_KSCRASH +// swiftlint:disable:next no_implementation_only_import +@_implementationOnly import KSCrashInstallations + +extension SentryKSCrash { + protocol Installing { + /// Install the crash handler. + /// - Parameters: + /// - installPath: The base directory for crash report storage. + /// - monitors: Monitor types to enable. + /// - enableSwapCxaThrow: Whether to swap `__cxa_throw` for better C++ stacks. + /// - Throws: Any error from `KSCrash.installWithConfiguration(_:error:)`. + func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws + + /// Uninstall the crash handler for the current SDK lifecycle. + func uninstall() + + /// Whether the previous run crashed. + var crashedLastLaunch: Bool { get } + + /// Whether this installer has successfully installed for the current SDK lifecycle. + /// + /// Tracked separately from KSCrash's process-lifetime `reportStore`, which stays + /// non-nil after `SentrySDK.close()`. + var installed: Bool { get } + } + + /// Configures and installs a crash handler. + final class Installer: SentryKSCrash.Installing { + private(set) var installed = false + + func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { + let config = KSCrashConfiguration() + config.installPath = installPath + config.monitors = MonitorType(rawValue: monitors) + config.enableSwapCxaThrow = enableSwapCxaThrow + do { + try KSCrash.shared.install(with: config) + } catch let error as NSError + where error.domain == "KSCrashErrorDomain" && error.code == 1 /* KSCrashInstallErrorAlreadyInstalled */ { + // KSCrash holds a process-lifetime C flag, so install() fails on every + // subsequent call within the same process (common during tests and SDK re-init). + // The crash handler is already running — treat this as success. + SentrySDKLog.debug("KSCrash already installed; continuing.") + } + installed = true + } + + func uninstall() { + // KSCrash itself cannot be uninstalled in-process (process-lifetime). + installed = false + } + + var crashedLastLaunch: Bool { KSCrash.shared.crashedLastLaunch } + } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift new file mode 100644 index 0000000000..5a986b70f5 --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -0,0 +1,79 @@ +#if ENABLE_KSCRASH +// swiftlint:disable:next no_implementation_only_import +@_implementationOnly import KSCrashInstallations +internal import _SentryPrivate +import Foundation + +// MARK: - Integration +extension SentryKSCrash { + typealias DependencyProvider = SentryKSCrash.InstallerProvider + + /// Crash detectors matching SentryCrash's production monitor set: + /// Mach exceptions, signals, C++ exceptions, and NSExceptions. + /// KSCrash unconditionally adds its Required monitors (System, AppState, + /// UserInfo, Resource) on top of whatever is passed here. + static let productionSafeMonitors: UInt = MonitorType([ + .machException, + .signal, + .cppException, + .nsException + ]).rawValue + + final class Integration: NSObject, SwiftIntegration { + private weak var options: Options? + private let installer: Dependencies.Installing + + // MARK: - Initialization + + init?(with options: Options, dependencies: Dependencies) { + guard options.enableCrashHandler else { + SentrySDKLog.debug("Not going to enable \(Self.name) because enableCrashHandler is disabled.") + return nil + } + + self.options = options + let installer = dependencies.getKSCrashInstaller() + self.installer = installer + super.init() + + // To match KSCrash & SentryCrash, we need to add 'KSCrash/' to the cacheDirectoryPath + let installPath = Self.installPath(for: options.cacheDirectoryPath, bundleInfo: Bundle.main.infoDictionary) + + do { + try installer.install( + installPath: installPath.path, + monitors: productionSafeMonitors, + enableSwapCxaThrow: options.experimental.enableUnhandledCPPExceptionsV2 + ) + } catch { + SentrySDKLog.error("KSCrash install failed: \(error)") + return nil + } + + if installer.crashedLastLaunch { + SentrySDKInternal.fatalDetected = true + } + } + + // MARK: - SwiftIntegration + static var name: String { + "SentryKSCrashIntegration" + } + + func uninstall() { + installer.uninstall() + } + + // MARK: - Helpers + /// Get the install path for the crash reporter + /// This method exists to centralize path building to save rebuilding it manually in tests + static func installPath(for cacheDirectoryPath: String, bundleInfo: [String: Any]?) -> URL { + let bundleName = bundleInfo?["CFBundleName"] as? String ?? "Unknown" + return URL(fileURLWithPath: cacheDirectoryPath) + .appendingPathComponent("KSCrash") + .appendingPathComponent(bundleName.replacingOccurrences(of: "/", with: "-")) + .absoluteURL + } + } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift new file mode 100644 index 0000000000..652441f413 --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift @@ -0,0 +1,45 @@ +#if ENABLE_KSCRASH + +extension SentryKSCrash { + protocol QueryProvider { + associatedtype Querying: SentryKSCrashQuerying + var kscrashQuery: Querying { get } + } + + // The compiler is unable to emit declarations for ObjC types nested in Swift types that + // cannot be represented in ObjC (i.e. enum -> class). So in order to keep the 'shape' of + // the SentryKSCrash API, we declare them as typealiases here + /// An ObjC-accessible class that surfaces live KSCrash crash state to ObjC callers without littering the SentryKSCrash types with `@objc`. + @_spi(Private) public typealias Query = SentryKSCrashQuery + /// Allows querying the state of the crash reporter. + @_spi(Private) public typealias Querying = SentryKSCrashQuerying +} + +/// Allows querying the state of the crash reporter. +@_spi(Private) +@objc(SentryKSCrashQuerying) +public protocol SentryKSCrashQuerying: NSObjectProtocol { + /// Whether KSCrash has been successfully installed this session. + @objc var installed: Bool { get } + /// Whether KSCrash crashed on the previous launch. + @objc var crashedLastLaunch: Bool { get } +} + +/// An ObjC-accessible class that surfaces live KSCrash crash state to ObjC callers without littering the SentryKSCrash types with `@objc`. +@_spi(Private) +@objc(SentryKSCrashQuery) +public final class SentryKSCrashQuery: NSObject, SentryKSCrashQuerying { + private let _installed: () -> Bool + private let _crashedLastLaunch: () -> Bool + + init(installer: some SentryKSCrash.Installing) { + _installed = { installer.installed } + _crashedLastLaunch = { installer.crashedLastLaunch } + } + + // swiftlint:disable missing_docs + @objc public var installed: Bool { _installed() } + @objc public var crashedLastLaunch: Bool { _crashedLastLaunch() } + // swiftlint:enable missing_docs +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift new file mode 100644 index 0000000000..3e19c75a2d --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift @@ -0,0 +1,12 @@ +#if ENABLE_KSCRASH +/// All types related to KSCrash should live under this type +@_spi(Private) public enum SentryKSCrash { + /// Provides a `KSCrashInstalling` instance for dependency injection. + protocol InstallerProvider { + /// The installer used to set up KSCrash crash reporting. + associatedtype Installing: SentryKSCrash.Installing + + func getKSCrashInstaller() -> Installing + } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift deleted file mode 100644 index 6d6874f00a..0000000000 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift +++ /dev/null @@ -1,38 +0,0 @@ -#if ENABLE_KSCRASH -// swiftlint:disable:next no_implementation_only_import -@_implementationOnly import KSCrashInstallations -internal import _SentryPrivate -import Foundation - -// MARK: - Dependency Provider - -/// Provides dependencies for `SentryKSCrashIntegration`. -typealias KSCrashIntegrationProvider = DateProviderProvider - -// MARK: - SentryKSCrashIntegration - -final class SentryKSCrashIntegration: NSObject, SwiftIntegration { - private weak var options: Options? - - // MARK: - Initialization - - init?(with options: Options, dependencies: Dependencies) { - guard options.enableCrashHandler else { - SentrySDKLog.debug("Not going to enable \(Self.name) because enableCrashHandler is disabled.") - return nil - } - - self.options = options - super.init() - - SentrySDKInternal.crashReporterInstalled = true - } - - // MARK: - SwiftIntegration - static var name: String { - "SentryKSCrashIntegration" - } - - func uninstall() {} -} -#endif diff --git a/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift b/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift index da129d0586..543ce67e96 100644 --- a/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift +++ b/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift @@ -156,7 +156,6 @@ final class SentryCrashIntegration: NSOb // SentrySDK.lastRunStatus returns a definitive answer and the integration // installer can determine the .didNotCrash case. We can't use // isIntegrationInstalled because it's set after init returns. - SentrySDKInternal.crashReporterInstalled = true if SentryDependencyContainer.sharedInstance().crashWrapper.crashedLastLaunch { SentrySDKInternal.fatalDetected = true } diff --git a/Sources/Swift/SentryCrash/SentryCrashReporter.swift b/Sources/Swift/SentryCrash/SentryCrashReporter.swift index f20fc8566d..b077e41bc9 100644 --- a/Sources/Swift/SentryCrash/SentryCrashReporter.swift +++ b/Sources/Swift/SentryCrash/SentryCrashReporter.swift @@ -2,6 +2,7 @@ import Foundation @_spi(Private) @objc public protocol SentryCrashReporter: NSObjectProtocol { + @objc var installed: Bool { get } @objc var crashedLastLaunch: Bool { get } @objc var durationFromCrashStateInitToLastCrash: TimeInterval { get } @objc var activeDurationSinceLastCrash: TimeInterval { get } diff --git a/Sources/Swift/SentryCrash/SentryCrashSwift.swift b/Sources/Swift/SentryCrash/SentryCrashSwift.swift index 6e1965231e..c1ac019a9a 100644 --- a/Sources/Swift/SentryCrash/SentryCrashSwift.swift +++ b/Sources/Swift/SentryCrash/SentryCrashSwift.swift @@ -73,7 +73,11 @@ private final class CrashReportFilterBridge: NSObject, SentryCrashReportFilter { @objc public func install() { sentryCrash.install() } - + + @objc public var installed: Bool { + sentryCrash.installed + } + @objc public func uninstall() { sentryCrash.uninstall() } diff --git a/Sources/Swift/SentryCrash/SentryDefaultCrashReporter.swift b/Sources/Swift/SentryCrash/SentryDefaultCrashReporter.swift index a100564790..38d1ec4577 100644 --- a/Sources/Swift/SentryCrash/SentryDefaultCrashReporter.swift +++ b/Sources/Swift/SentryCrash/SentryDefaultCrashReporter.swift @@ -73,6 +73,11 @@ public final class SentryDefaultCrashReporter: NSObject, SentryCrashReporter { sentrycrashbic_stopCache() } + @objc + public var installed: Bool { + return bridge.crashReporter.installed + } + @objc public var crashedLastLaunch: Bool { return bridge.crashReporter.crashedLastLaunch diff --git a/Sources/Swift/SentryDependencyContainer.swift b/Sources/Swift/SentryDependencyContainer.swift index ac844a8e31..5df656f8aa 100644 --- a/Sources/Swift/SentryDependencyContainer.swift +++ b/Sources/Swift/SentryDependencyContainer.swift @@ -440,6 +440,21 @@ extension SentryFileManager: SentryFileManagerProtocol { } processInfoWrapper: processInfoWrapper ) } + +#if ENABLE_KSCRASH + private var kscrashInstaller: SentryKSCrash.Installer? + func getKSCrashInstaller() -> SentryKSCrash.Installer { + getLazyVar(\.kscrashInstaller) { + SentryKSCrash.Installer() + } + } + + private var _kscrashQuery: SentryKSCrash.Query? + @objc public lazy var kscrashQuery: SentryKSCrash.Query = getLazyVar(\._kscrashQuery) { + SentryKSCrash.Query(installer: getKSCrashInstaller()) + } + +#endif } // swiftlint:enable type_body_length @@ -519,6 +534,11 @@ extension SentryDependencyContainer: DateProviderProvider {} extension SentryDependencyContainer: AutoSessionTrackingProvider { } +#if ENABLE_KSCRASH +extension SentryDependencyContainer: SentryKSCrash.InstallerProvider {} +extension SentryDependencyContainer: SentryKSCrash.QueryProvider {} +#endif + protocol FileIOTrackerProvider { var fileIOTracker: SentryFileIOTracker { get } } diff --git a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift new file mode 100644 index 0000000000..97bfdaf789 --- /dev/null +++ b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift @@ -0,0 +1,38 @@ +#if ENABLE_KSCRASH +@_spi(Private) @testable import Sentry + +final class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { + typealias Installing = MockKSCrashInstaller + + let kscrashInstaller: MockKSCrashInstaller + + init(installer: MockKSCrashInstaller = .init()) { + self.kscrashInstaller = installer + } + + func getKSCrashInstaller() -> MockKSCrashInstaller { + return kscrashInstaller + } +} + +final class MockKSCrashInstaller: SentryKSCrash.Installing { + public var installCalls: [(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool)] = [] + public var uninstallCallCount = 0 + public var shouldThrow: Error? + public var crashedLastLaunch: Bool = false + public var installed: Bool = false + + public init() {} + + public func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { + installCalls.append((installPath: installPath, monitors: monitors, enableSwapCxaThrow: enableSwapCxaThrow)) + if let error = shouldThrow { throw error } + installed = true + } + + public func uninstall() { + uninstallCallCount += 1 + installed = false + } +} +#endif diff --git a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashQuery.swift b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashQuery.swift new file mode 100644 index 0000000000..3fb310d566 --- /dev/null +++ b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashQuery.swift @@ -0,0 +1,16 @@ +#if ENABLE_KSCRASH +@_spi(Private) @testable import Sentry + +enum MockKSCrashQuery { + static func create( + installed: Bool = false, + crashedLastLaunch: Bool = false + ) -> SentryKSCrash.Query { + let mockInstaller = MockKSCrashInstaller() + mockInstaller.installed = installed + mockInstaller.crashedLastLaunch = crashedLastLaunch + + return SentryKSCrash.Query(installer: mockInstaller) + } +} +#endif diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift new file mode 100644 index 0000000000..5e96b390a1 --- /dev/null +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -0,0 +1,220 @@ +#if ENABLE_KSCRASH +@_spi(Private) import SentryTestUtils +@_spi(Private) @testable import Sentry +import XCTest + +class SentryKSCrashIntegrationTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + SentrySDKInternal.fatalDetected = false + } + + override func tearDown() { + super.tearDown() + SentrySDKInternal.fatalDetected = false + } + + private func makeOptions(enableCrashHandler: Bool = true) -> Options { + let options = Options() + options.enableCrashHandler = enableCrashHandler + return options + } + + func testInstall_whenCrashHandlerEnabled_shouldCallInstallOnce() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + let options = makeOptions() + + // -- Act -- + let sut = SentryKSCrash.Integration(with: options, dependencies: deps) + + // -- Assert -- + XCTAssertNotNil(sut) + XCTAssertEqual(installer.installCalls.count, 1) + XCTAssertEqual(installer.installCalls[0].monitors, SentryKSCrash.productionSafeMonitors) + } + + func testInstall_whenCrashHandlerEnabled_shouldAppendKSCrashBundleSubdirectory() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + let options = makeOptions() + let expectedPath = SentryKSCrash.Integration.installPath( + for: options.cacheDirectoryPath, + bundleInfo: Bundle.main.infoDictionary + ).path + + // -- Act -- + _ = SentryKSCrash.Integration(with: options, dependencies: deps) + + // -- Assert -- + XCTAssertEqual(installer.installCalls[0].installPath, expectedPath) + } + + // MARK: - installPath helper + + func testInstallPath_appendsKSCrashAndBundleName() { + // -- Arrange -- + let cacheDirectory = "/var/mobile/Containers/Data/app" + let bundleInfo = ["CFBundleName": "MyApp"] + + // -- Act -- + let result = SentryKSCrash.Integration.installPath( + for: cacheDirectory, + bundleInfo: bundleInfo + ) + + // -- Assert -- + XCTAssertEqual(result.path, "/var/mobile/Containers/Data/app/KSCrash/MyApp") + } + + func testInstallPath_sanitizesSlashesInBundleName() { + // -- Arrange + let cacheDirectory = "/cache" + let bundleInfo = ["CFBundleName": "My/App/Name"] + + // -- Act -- + let result = SentryKSCrash.Integration.installPath( + for: cacheDirectory, + bundleInfo: bundleInfo + ) + + // -- Assert + XCTAssertEqual(result.path, "/cache/KSCrash/My-App-Name") + } + + func testInstallPath_fallsBackToUnknown_whenBundleInfoIsNil() { + // -- Arrange -- + let cacheDirectory = "/cache" + let bundleInfo: [String: Any]? = nil + + // -- Act -- + let result = SentryKSCrash.Integration.installPath( + for: cacheDirectory, + bundleInfo: bundleInfo + ) + + // -- Assert -- + XCTAssertEqual(result.path, "/cache/KSCrash/Unknown") + } + + func testInstallPath_fallsBackToUnknown_whenCFBundleNameKeyMissing() { + // -- Arrange + let cacheDirectory = "/cache" + let bundleInfo = ["CFBundleIdentifier": "com.example.app"] + + // -- Act -- + let result = SentryKSCrash.Integration.installPath( + for: cacheDirectory, + bundleInfo: bundleInfo + ) + + // -- Assert -- + XCTAssertEqual(result.path, "/cache/KSCrash/Unknown") + } + + func testInstall_whenInstallThrows_shouldReturnNil() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) + let deps = MockKSCrashDependencies(installer: installer) + SentryDependencyContainer.sharedInstance().kscrashQuery = SentryKSCrash.Query(installer: installer) + + // -- Act -- + let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + + // -- Assert -- + XCTAssertNil(sut) + } + + // MARK: - Last-run crash APIs + + func testInstall_whenCrashedLastLaunch_shouldSetFatalDetected() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = true + let deps = MockKSCrashDependencies(installer: installer) + + // -- Act -- + _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + + // -- Assert -- + XCTAssertTrue(SentrySDKInternal.fatalDetected) + } + + func testInstall_whenDidNotCrashLastLaunch_shouldNotSetFatalDetected() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = false + let deps = MockKSCrashDependencies(installer: installer) + + // -- Act -- + _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + + // -- Assert -- + XCTAssertFalse(SentrySDKInternal.fatalDetected) + } + + func testInstall_whenInstallSucceeds_shouldMarkInstallerInstalled() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + SentryDependencyContainer.sharedInstance().kscrashQuery = SentryKSCrash.Query(installer: installer) + + // -- Act -- + _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + + // -- Assert -- + XCTAssertTrue(installer.installed) + XCTAssertTrue(SentrySDKInternal.crashReporterInstalled) + } + + func testInstall_whenInstallThrows_shouldNotSetCrashReporterInstalled() { + // -- Arrange + let installer = MockKSCrashInstaller() + installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) + let deps = MockKSCrashDependencies(installer: installer) + SentryDependencyContainer.sharedInstance().kscrashQuery = SentryKSCrash.Query(installer: installer) + + // -- Act -- + _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + + // -- Assert -- + XCTAssertFalse(installer.installed) + XCTAssertFalse(SentrySDKInternal.crashReporterInstalled) + } + + func testInstall_whenCrashHandlerDisabled_shouldSkipInstall() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + + // -- Act -- + let sut = SentryKSCrash.Integration(with: makeOptions(enableCrashHandler: false), dependencies: deps) + + // -- Assert -- + XCTAssertNil(sut) + XCTAssertEqual(installer.installCalls.count, 0) + XCTAssertFalse(installer.installed) + } + + func testUninstall_shouldClearInstallerInstalled() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + SentryDependencyContainer.sharedInstance().kscrashQuery = SentryKSCrash.Query(installer: installer) + let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + XCTAssertTrue(installer.installed) + XCTAssertTrue(SentrySDKInternal.crashReporterInstalled) + + // -- Act -- + sut?.uninstall() + + // -- Assert -- + XCTAssertEqual(installer.uninstallCallCount, 1) + XCTAssertFalse(installer.installed) + XCTAssertFalse(SentrySDKInternal.crashReporterInstalled) + } +} +#endif diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift new file mode 100644 index 0000000000..08e23b4ec1 --- /dev/null +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift @@ -0,0 +1,87 @@ +#if ENABLE_KSCRASH +@_spi(Private) @testable import Sentry +import XCTest + +class SentryKSCrashQueryTests: XCTestCase { + + // MARK: - installed + + func testInstalled_whenInstallerIsInstalled_shouldReturnTrue() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.installed = true + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + let result = sut.installed + + // -- Assert -- + XCTAssertTrue(result) + } + + func testInstalled_whenInstallerIsNotInstalled_shouldReturnFalse() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.installed = false + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + let result = sut.installed + + // -- Assert -- + XCTAssertFalse(result) + } + + func testInstalled_whenInstallerStateChangesAfterInit_shouldReflectCurrentState() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + installer.installed = true + + // -- Assert -- + XCTAssertTrue(sut.installed) + } + + // MARK: - crashedLastLaunch + + func testCrashedLastLaunch_whenInstallerCrashedLastLaunch_shouldReturnTrue() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = true + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + let result = sut.crashedLastLaunch + + // -- Assert -- + XCTAssertTrue(result) + } + + func testCrashedLastLaunch_whenInstallerDidNotCrashLastLaunch_shouldReturnFalse() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = false + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + let result = sut.crashedLastLaunch + + // -- Assert -- + XCTAssertFalse(result) + } + + func testCrashedLastLaunch_whenInstallerStateChangesAfterInit_shouldReflectCurrentState() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + installer.crashedLastLaunch = true + + // -- Assert -- + XCTAssertTrue(sut.crashedLastLaunch) + } +} +#endif diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 6f9a84485d..36e9310abf 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -27,6 +27,7 @@ class SentrySessionReplayIntegrationTests: XCTestCase { super.init() } + var installed: Bool { false } var crashedLastLaunch: Bool { false } var durationFromCrashStateInitToLastCrash: TimeInterval { 0 } var activeDurationSinceLastCrash: TimeInterval { 0 } diff --git a/Tests/SentryTests/SentryCrash/TestSentryCrashWrapper.swift b/Tests/SentryTests/SentryCrash/TestSentryCrashWrapper.swift index 6905a681f5..4e0c21a1f4 100644 --- a/Tests/SentryTests/SentryCrash/TestSentryCrashWrapper.swift +++ b/Tests/SentryTests/SentryCrash/TestSentryCrashWrapper.swift @@ -7,6 +7,7 @@ class TestSentryCrashReporter: NSObject, SentryCrashReporter { // MARK: - Test Properties + var internalInstalled = false var internalCrashedLastLaunch = false var internalDurationFromCrashStateInitToLastCrash: TimeInterval = 0 var internalActiveDurationSinceLastCrash: TimeInterval = 0 @@ -31,6 +32,7 @@ class TestSentryCrashReporter: NSObject, SentryCrashReporter { // MARK: - SentryCrashReporter Protocol + var installed: Bool { internalInstalled } var crashedLastLaunch: Bool { internalCrashedLastLaunch } var durationFromCrashStateInitToLastCrash: TimeInterval { internalDurationFromCrashStateInitToLastCrash } var activeDurationSinceLastCrash: TimeInterval { internalActiveDurationSinceLastCrash } diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 275d4f8c65..22c0df178b 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -241,7 +241,14 @@ class SentrySDKTests: XCTestCase { func testLastRunStatus_whenCrashStateNotLoaded_shouldReturnUnknown() { // -- Arrange -- - SentrySDKInternal.crashReporterInstalled = false +#if ENABLE_KSCRASH + let mockQuery = MockKSCrashQuery.create(installed: false, crashedLastLaunch: false) + SentryDependencyContainer.sharedInstance().kscrashQuery = mockQuery +#else + let crashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) + crashWrapper.internalInstalled = false + SentryDependencyContainer.sharedInstance().crashWrapper = crashWrapper +#endif // -- Act -- let status = SentrySDK.lastRunStatus @@ -252,8 +259,15 @@ class SentrySDKTests: XCTestCase { func testLastRunStatus_whenCrashStateLoadedAndNoCrash_shouldReturnDidNotCrash() { // -- Arrange -- - SentrySDKInternal.crashReporterInstalled = true - // The default test crash reporter returns false for crashedLastLaunch +#if ENABLE_KSCRASH + let mockQuery = MockKSCrashQuery.create(installed: true, crashedLastLaunch: false) + SentryDependencyContainer.sharedInstance().kscrashQuery = mockQuery +#else + let crashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) + crashWrapper.internalInstalled = true + crashWrapper.internalCrashedLastLaunch = false + SentryDependencyContainer.sharedInstance().crashWrapper = crashWrapper +#endif // -- Act -- let status = SentrySDK.lastRunStatus @@ -264,10 +278,15 @@ class SentrySDKTests: XCTestCase { func testLastRunStatus_whenCrashStateLoadedAndCrashed_shouldReturnDidCrash() { // -- Arrange -- - SentrySDKInternal.crashReporterInstalled = true +#if ENABLE_KSCRASH + let mockQuery = MockKSCrashQuery.create(installed: true, crashedLastLaunch: true) + SentryDependencyContainer.sharedInstance().kscrashQuery = mockQuery +#else let crashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) + crashWrapper.internalInstalled = true crashWrapper.internalCrashedLastLaunch = true SentryDependencyContainer.sharedInstance().crashWrapper = crashWrapper +#endif // -- Act -- let status = SentrySDK.lastRunStatus @@ -288,6 +307,7 @@ class SentrySDKTests: XCTestCase { // -- Assert -- XCTAssertEqual(status, .unknown) + XCTAssertFalse(SentrySDKInternal.crashReporterInstalled) } // MARK: - onLastRunStatusDetermined