Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,6 @@ fastlane/test_output

buildboth.sh
Pods
.build/
Package.resolved
.swiftpm/
58 changes: 58 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// swift-tools-version: 5.5
// CrashKiOS Swift sinks — live in the app's build graph, where the crash-SDK
// binaries are already linked. See docs-plans/crashkios-linking-remediation-plan.md.

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"
),
]
)
103 changes: 103 additions & 0 deletions Sources/CrashKiOSBugsnag/BugsnagSink.swift
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))
}

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
}
}
1 change: 1 addition & 0 deletions Sources/CrashKiOSBugsnagObjC/CrashKiOSBugsnagObjC.m
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.
41 changes: 41 additions & 0 deletions Sources/CrashKiOSBugsnagObjC/include/CrashKiOSBugsnagSink.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#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 registered with the Kotlin side via `registerBugsnagSink`.
///
/// 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 */
46 changes: 46 additions & 0 deletions Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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 as Any, forKey: key)
}

public func setUserId(_ identifier: String) {
crashlytics.setUserID(identifier)
}
}
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,37 @@
#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 registered with the Kotlin side via `registerCrashlyticsSink`.
///
/// 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;

@end

#endif /* CrashKiOSCrashlyticsSink_h */
30 changes: 30 additions & 0 deletions Sources/CrashKiOSCrashlyticsObjC/include/FIRCLSException.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 */
Loading
Loading