-
Notifications
You must be signed in to change notification settings - Fork 17
Linking issue fix proposal #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
64c694e
72eb306
b94b9f4
098fedb
1db8953
ccdf2c4
fd80639
e6465ca
89204c7
2f1580c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -71,3 +71,6 @@ fastlane/test_output | |
|
|
||
| buildboth.sh | ||
| Pods | ||
| .build/ | ||
| Package.resolved | ||
| .swiftpm/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| // swift-tools-version: 5.5 | ||
|
|
||
| import PackageDescription | ||
|
|
||
| let package = Package( | ||
| name: "CrashKiOS", | ||
| platforms: [.iOS(.v15), .macOS(.v10_15), .tvOS(.v15), .watchOS(.v7)], | ||
| products: [ | ||
| .library( | ||
| name: "CrashKiOSCrashlytics", | ||
| targets: ["CrashKiOSCrashlytics"] | ||
| ), | ||
| .library( | ||
| name: "CrashKiOSBugsnag", | ||
| targets: ["CrashKiOSBugsnag"] | ||
| ), | ||
| ], | ||
| dependencies: [ | ||
| .package( | ||
| url: "https://github.com/firebase/firebase-ios-sdk.git", | ||
| "11.0.0"..<"13.0.0" | ||
| ), | ||
| .package( | ||
| url: "https://github.com/bugsnag/bugsnag-cocoa.git", | ||
| "6.22.1"..<"7.0.0" | ||
| ), | ||
| ], | ||
| targets: [ | ||
| // Header-only targets: they own the sink protocols (cinterop'd by the Kotlin | ||
| // modules) so Swift sinks compile without importing the app's Kotlin framework. | ||
| .target( | ||
| name: "CrashKiOSCrashlyticsObjC", | ||
| path: "Sources/CrashKiOSCrashlyticsObjC" | ||
| ), | ||
| .target( | ||
| name: "CrashKiOSCrashlytics", | ||
| dependencies: [ | ||
| .target(name: "CrashKiOSCrashlyticsObjC"), | ||
| .product(name: "FirebaseCrashlytics", package: "firebase-ios-sdk"), | ||
| ], | ||
| path: "Sources/CrashKiOSCrashlytics" | ||
| ), | ||
| .target( | ||
| name: "CrashKiOSBugsnagObjC", | ||
| path: "Sources/CrashKiOSBugsnagObjC" | ||
| ), | ||
| .target( | ||
| name: "CrashKiOSBugsnag", | ||
| dependencies: [ | ||
| .target(name: "CrashKiOSBugsnagObjC"), | ||
| .product(name: "Bugsnag", package: "bugsnag-cocoa"), | ||
| ], | ||
| path: "Sources/CrashKiOSBugsnag" | ||
| ), | ||
| ] | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import Foundation | ||
| import ObjectiveC | ||
| import CrashKiOSBugsnagObjC | ||
| import Bugsnag | ||
|
|
||
| /// Feature flag used to mark the Kotlin termination crash. | ||
| /// Public so custom `CrashKiOSBugsnagSink` implementations can honor the same contract. | ||
| public let kotlinCrashedFeatureFlag = "crashkios.kotlin_crashed" | ||
|
|
||
| /// Prepares `config` for Kotlin crash handling: installs the OnSendError filter that | ||
| /// suppresses the duplicate termination crash following a recorded Kotlin fatal, and | ||
| /// works around Bugsnag 6.26.2+ refusing to persist synthetic unhandled events. | ||
| /// | ||
| /// Custom sink implementations MUST call this before `Bugsnag.start(with: config)` — | ||
| /// otherwise every fatal Kotlin exception is reported twice. | ||
| public func configureBugsnagForKotlin(_ config: BugsnagConfiguration) { | ||
| overrideOriginalUnhandledValue() | ||
| config.addOnSendError { event in | ||
| // Drop Bugsnag's own report of the abort that terminates the app after a | ||
| // Kotlin fatal was already recorded (flagged via markFatalCrashRecorded). | ||
| !event.unhandled || !event.featureFlags.contains { $0.name == kotlinCrashedFeatureFlag } | ||
| } | ||
| config.clearFeatureFlag(name: kotlinCrashedFeatureFlag) | ||
| } | ||
|
|
||
| /// Reference `CrashKiOSBugsnagSink` implementation. | ||
| /// | ||
| /// Start Bugsnag through this class so the configuration is prepared for Kotlin | ||
| /// crash handling, then configure CrashKiOS with the returned sink: | ||
| /// ```swift | ||
| /// let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) | ||
| /// CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink: sink)) | ||
| /// ``` | ||
| /// (Add `export("co.touchlab.crashkios:bugsnag")` AND `export("co.touchlab.crashkios:core")` | ||
| /// to the Kotlin framework for clean names.) | ||
| public final class BugsnagSink: NSObject, CrashKiOSBugsnagSink { | ||
|
|
||
| /// Configures `config` via `configureBugsnagForKotlin(_:)`, starts Bugsnag, | ||
| /// and returns the sink. | ||
| public static func start(_ config: BugsnagConfiguration) -> BugsnagSink { | ||
| configureBugsnagForKotlin(config) | ||
| Bugsnag.start(with: config) | ||
| return BugsnagSink() | ||
| } | ||
|
|
||
| public func leaveBreadcrumb(_ message: String) { | ||
| Bugsnag.leaveBreadcrumb(withMessage: message) | ||
| } | ||
|
|
||
| public func notify(with exceptions: [NSException], handled: Bool) { | ||
| guard let exception = exceptions.first else { return } | ||
| // Notify persists unhandled events, so the caller can safely terminate afterwards. | ||
| // https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/Client/BugsnagClient.m#L744 | ||
| Bugsnag.notify(exception) { event in | ||
| if handled { | ||
| event.severity = .warning | ||
| } else { | ||
| event.unhandled = true | ||
| event.severity = .error | ||
| } | ||
| event.errors += exceptions.dropFirst().map(BugsnagError.init) | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| public func addMetadata(_ value: Any, key: String, section: String) { | ||
| Bugsnag.addMetadata(value, key: key, section: section) | ||
| } | ||
|
|
||
| public func markFatalCrashRecorded() { | ||
| // Called only by the Kotlin unhandled-exception hook, right before termination — | ||
| // never on direct sendFatalException() calls, which would poison the session and | ||
| // make the OnSendError filter discard later genuine crashes. | ||
| Bugsnag.addFeatureFlag(name: kotlinCrashedFeatureFlag) | ||
| } | ||
| } | ||
|
|
||
| /// In Bugsnag 6.26.2+ the `originalUnhandledValue` property prevents our synthetic | ||
| /// unhandled exceptions from being stored to disk; alias it to `unhandled`. | ||
| /// https://github.com/bugsnag/bugsnag-cocoa/pull/1549 | ||
| private func overrideOriginalUnhandledValue() { | ||
| guard let handledStateClass = NSClassFromString("BugsnagHandledState"), | ||
| let originalMethod = class_getInstanceMethod(handledStateClass, NSSelectorFromString("originalUnhandledValue")), | ||
| let method = class_getInstanceMethod(handledStateClass, NSSelectorFromString("unhandled")) | ||
| else { | ||
| // Loud, not silent: if Bugsnag renames these internals, fatal Kotlin crashes | ||
| // would silently stop reaching the dashboard (>=6.26.2 refuses to persist them). | ||
| assertionFailure("CrashKiOS: BugsnagHandledState internals changed — Kotlin fatal crashes may not be persisted. Update CrashKiOSBugsnag.") | ||
| return | ||
| } | ||
| method_setImplementation(originalMethod, method_getImplementation(method)) | ||
| } | ||
|
Comment on lines
+81
to
+92
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A missing private func overrideOriginalUnhandledValue() {
guard let handledStateClass = NSClassFromString("BugsnagHandledState"),
let method = class_getInstanceMethod(handledStateClass, NSSelectorFromString("unhandled"))
else {
assertionFailure("CrashKiOS: BugsnagHandledState internals changed — Kotlin fatal crashes may not be persisted. Update CrashKiOSBugsnag.")
return
}
// Bugsnag < 6.26.2 has no originalUnhandledValue: nothing to override.
guard let originalMethod = class_getInstanceMethod(handledStateClass, NSSelectorFromString("originalUnhandledValue")) else { return }
method_setImplementation(originalMethod, method_getImplementation(method))
} |
||
|
|
||
| private extension BugsnagError { | ||
| /// Creates a BugsnagError from a (Kotlin-synthesized) NSException. | ||
| convenience init(_ exception: NSException) { | ||
| self.init() | ||
| errorClass = exception.name.rawValue | ||
| errorMessage = exception.reason | ||
| stacktrace = BugsnagStackframe.stackframes(withCallStackReturnAddresses: exception.callStackReturnAddresses) | ||
| type = .cocoa | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| // We need this empty file, else SPM won't build this header-only target. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| #ifndef CrashKiOSBugsnagSink_h | ||
| #define CrashKiOSBugsnagSink_h | ||
|
|
||
| #import <Foundation/Foundation.h> | ||
|
|
||
| /// Receives crash-reporting events from Kotlin and forwards them to Bugsnag. | ||
| /// | ||
| /// Implemented in Swift, inside the app's build graph — where Bugsnag is already | ||
| /// linked — and handed to the Kotlin side with | ||
| /// `CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink:))`. | ||
| /// | ||
| /// Contract for implementations (the shipped `BugsnagSink` does all of this): | ||
| /// - MUST NOT throw: Kotlin cannot catch ObjC exceptions thrown across this | ||
| /// boundary (KT-53004), and the fatal path runs inside the unhandled-exception hook. | ||
| /// - MUST prepare the Bugsnag configuration with `configureBugsnagForKotlin(_:)` | ||
| /// (CrashKiOSBugsnag package) before `Bugsnag.start`, otherwise every fatal Kotlin | ||
| /// exception is reported twice (the Kotlin notify plus Bugsnag's own report of the | ||
| /// termination abort). | ||
| @protocol CrashKiOSBugsnagSink <NSObject> | ||
|
|
||
| /// Bugsnag `leaveBreadcrumbWithMessage:`. | ||
| - (void)leaveBreadcrumb:(NSString * _Nonnull)message; | ||
|
|
||
| /// Notify Bugsnag of a Kotlin exception. `exceptions` is `[main, cause, causeOfCause, ...]`; | ||
| /// each element's `callStackReturnAddresses` carries its Kotlin stack trace. | ||
| /// When `handled` is NO the event must be persisted synchronously before returning | ||
| /// (`Bugsnag.notify` does) — the process may terminate right after. | ||
| - (void)notifyWithExceptions:(NSArray<NSException *> * _Nonnull)exceptions handled:(BOOL)handled; | ||
|
|
||
| /// Bugsnag `addMetadata:key:section:`. | ||
| - (void)addMetadata:(id _Nonnull)value key:(NSString * _Nonnull)key section:(NSString * _Nonnull)section; | ||
|
|
||
| /// Called by the Kotlin unhandled-exception hook — and ONLY that hook — right after | ||
| /// the fatal notify, just before the process terminates. Implementations flag the | ||
| /// session (Bugsnag feature flag `crashkios.kotlin_crashed`) so the OnSendError filter | ||
| /// installed by `configureBugsnagForKotlin(_:)` drops Bugsnag's own report of the | ||
| /// termination abort. Direct `sendFatalException()` calls do NOT trigger this. | ||
| - (void)markFatalCrashRecorded; | ||
|
|
||
| @end | ||
|
|
||
| #endif /* CrashKiOSBugsnagSink_h */ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import Foundation | ||
| import CrashKiOSCrashlyticsObjC | ||
| import FirebaseCrashlytics | ||
|
|
||
| /// Reference `CrashKiOSCrashlyticsSink` implementation. | ||
| /// | ||
| /// Configure from your `AppDelegate`, right after Firebase is configured and before | ||
| /// any Kotlin code runs: | ||
| /// ```swift | ||
| /// FirebaseApp.configure() | ||
| /// CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink: CrashlyticsSink())) | ||
| /// ``` | ||
| /// (Add `export("co.touchlab.crashkios:crashlytics")` AND `export("co.touchlab.crashkios:core")` | ||
| /// to the framework for clean names.) | ||
| public final class CrashlyticsSink: NSObject, CrashKiOSCrashlyticsSink { | ||
|
|
||
| private let crashlytics: Crashlytics | ||
|
|
||
| public init(_ crashlytics: Crashlytics = Crashlytics.crashlytics()) { | ||
| self.crashlytics = crashlytics | ||
| } | ||
|
|
||
| public func logMessage(_ message: String) { | ||
| crashlytics.log(message) | ||
| } | ||
|
|
||
| public func recordHandledException(withName name: String, reason: String, stackAddresses: [NSNumber]) { | ||
| let model = ExceptionModel(name: name, reason: reason) | ||
| model.stackTrace = stackAddresses.map { StackFrame(address: $0.uintValue) } | ||
| crashlytics.record(exceptionModel: model) | ||
| } | ||
|
|
||
| public func recordFatalException(_ exception: NSException) { | ||
| // Persists synchronously; Crashlytics stores a single fatal per session, so the | ||
| // termination abort that follows is not double-reported. | ||
| FIRCLSExceptionRecordNSException(exception) | ||
| } | ||
|
|
||
| public func setCustomValue(_ value: Any?, forKey key: String) { | ||
| crashlytics.setCustomValue(value, forKey: key) | ||
| } | ||
|
Copilot marked this conversation as resolved.
|
||
|
|
||
| public func setUserId(_ identifier: String) { | ||
| crashlytics.setUserID(identifier) | ||
| } | ||
|
|
||
| public func setCollectionEnabled(_ enabled: Bool) { | ||
| crashlytics.setCrashlyticsCollectionEnabled(enabled) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| // We need this empty file, else SPM won't build this header-only target. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| #ifndef CrashKiOSCrashlyticsSink_h | ||
| #define CrashKiOSCrashlyticsSink_h | ||
|
|
||
| #import <Foundation/Foundation.h> | ||
|
|
||
| /// Receives crash-reporting events from Kotlin and forwards them to Firebase Crashlytics. | ||
| /// | ||
| /// Implemented in Swift, inside the app's build graph — where FirebaseCrashlytics is | ||
| /// already linked — and handed to the Kotlin side with | ||
| /// `CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink:))`. | ||
| /// | ||
| /// Implementations MUST NOT throw: Kotlin cannot catch ObjC exceptions thrown across | ||
| /// this boundary (KT-53004), and the fatal path runs inside the unhandled-exception hook. | ||
| @protocol CrashKiOSCrashlyticsSink <NSObject> | ||
|
|
||
| /// Crashlytics `log:`. | ||
| - (void)logMessage:(NSString * _Nonnull)message; | ||
|
|
||
| /// A non-fatal exception. `addresses` are Kotlin stack-trace return addresses, | ||
| /// mappable to `FIRStackFrame stackFrameWithAddress:`. | ||
| - (void)recordHandledExceptionWithName:(NSString * _Nonnull)name | ||
| reason:(NSString * _Nonnull)reason | ||
| stackAddresses:(NSArray<NSNumber *> * _Nonnull)addresses; | ||
|
|
||
| /// A fatal exception. The process terminates right after this returns — | ||
| /// the record must be persisted synchronously (FIRCLSExceptionRecordNSException does). | ||
| /// `exception.callStackReturnAddresses` carries the Kotlin stack trace. | ||
| - (void)recordFatalException:(NSException * _Nonnull)exception; | ||
|
|
||
| /// Crashlytics `setCustomValue:forKey:`. | ||
| - (void)setCustomValue:(id _Nullable)value forKey:(NSString * _Nonnull)key; | ||
|
|
||
| /// Crashlytics `setUserID:`. | ||
| - (void)setUserId:(NSString * _Nonnull)identifier; | ||
|
|
||
| /// Crashlytics `setCrashlyticsCollectionEnabled:`. | ||
| - (void)setCollectionEnabled:(BOOL)enabled; | ||
|
|
||
| @end | ||
|
|
||
| #endif /* CrashKiOSCrashlyticsSink_h */ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| #ifndef CrashKiOSFIRCLSException_h | ||
| #define CrashKiOSFIRCLSException_h | ||
|
|
||
| // Declaration copied from firebase-ios-sdk's private header: | ||
| // https://github.com/firebase/firebase-ios-sdk/blob/main/Crashlytics/Crashlytics/Handlers/FIRCLSException.h | ||
| // | ||
| // Copyright 2019-2020 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| #import <Foundation/Foundation.h> | ||
|
|
||
| // Records `exception` as the session's fatal exception and persists it synchronously. | ||
| // Private Crashlytics SPI, resolved HARD at app link time via the FirebaseCrashlytics | ||
| // product this target depends on — nothing dynamic is left for the App Store's | ||
| // deployment-processing strip pass to break (the CrashKiOS <= 0.9.0 failure mode). | ||
| // Fallback if Firebase ever removes it: -[FIRCrashlytics recordOnDemandExceptionModel:]. | ||
| extern void FIRCLSExceptionRecordNSException(NSException *exception); | ||
|
|
||
| #endif /* CrashKiOSFIRCLSException_h */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
originalUnhandledValueonly exists from bugsnag-cocoa 6.26.2 (it's absent fromBugsnagHandledState.hin v6.26.1). With 6.22.1–6.26.1, which this range allows,overrideOriginalUnhandledValue()hitsassertionFailureand the app traps at startup in Debug.Verified: I pinned Bugsnag to exactly 6.26.1 in an app using
CrashKiOSBugsnag, and the Debug launch crashed:Release builds are unaffected because
assertionFailureis compiled out.We should raise the floor to
"6.26.2"..<"7.0.0"so SwiftPM rejects old versions at resolution time instead of the app trapping at runtime (and see the next comment).