// ── Crash Reporter ────────────────────────────────────────── // Generic MetricKit-based crash and performance reporting. // Stores crash diagnostics locally for debugging and feedback forms. // Product apps configure with a product-specific persistence key. // Not available on watchOS — MetricKit is iOS/macOS only. import Foundation /// Crash report model stored locally. /// Available on all platforms (data-only struct). public struct BLCrashReport: Codable, Identifiable, Sendable { public let id: String public let date: Date public let exceptionType: String? public let signal: String? public let terminationReason: String? public let callStackData: Data? public init(date: Date, exceptionType: String?, signal: String?, terminationReason: String?, callStackTree: Data) { self.id = UUID().uuidString self.date = date self.exceptionType = exceptionType self.signal = signal self.terminationReason = terminationReason self.callStackData = callStackTree } } #if canImport(MetricKit) import MetricKit /// Generic MetricKit crash reporter for all ByteLyst iOS/macOS apps. /// Subscribes to MetricKit, stores crash reports in UserDefaults. /// Not available on watchOS (MetricKit is iOS 13+ / macOS 12+ only). @MainActor public final class BLCrashReporter: NSObject, ObservableObject, MXMetricManagerSubscriber { private let persistenceKey: String private let maxReports: Int @Published public var lastCrashReport: Date? @Published public var diagnosticCount: Int = 0 public init(productId: String, maxReports: Int = 50) { self.persistenceKey = "\(productId)-crash-reports" self.maxReports = maxReports super.init() MXMetricManager.shared.add(self) loadStats() } deinit { MXMetricManager.shared.remove(self) } // MARK: - MXMetricManagerSubscriber nonisolated public func didReceive(_ payloads: [MXMetricPayload]) { // MetricKit delivers daily aggregated metrics — no action needed by default } nonisolated public func didReceive(_ payloads: [MXDiagnosticPayload]) { Task { @MainActor [weak self] in guard let self else { return } for payload in payloads { self.processDiagnosticPayload(payload) } self.diagnosticCount += payloads.count self.lastCrashReport = Date() } } // MARK: - Public API /// Get all stored crash reports. public func loadCrashReports() -> [BLCrashReport] { guard let data = UserDefaults.standard.data(forKey: persistenceKey), let reports = try? JSONDecoder().decode([BLCrashReport].self, from: data) else { return [] } return reports } /// Clear all stored crash reports. public func clearReports() { UserDefaults.standard.removeObject(forKey: persistenceKey) diagnosticCount = 0 } // MARK: - Private private func processDiagnosticPayload(_ payload: MXDiagnosticPayload) { if let crashDiagnostics = payload.crashDiagnostics { for crash in crashDiagnostics { let report = BLCrashReport( date: Date(), exceptionType: crash.exceptionType?.description, signal: crash.signal?.description, terminationReason: crash.terminationReason?.description, callStackTree: crash.callStackTree.jsonRepresentation() ) storeCrashReport(report) } } if let hangDiagnostics = payload.hangDiagnostics { for hang in hangDiagnostics { let report = BLCrashReport( date: Date(), exceptionType: nil, signal: nil, terminationReason: "Hang: \(hang.hangDuration.description)", callStackTree: hang.callStackTree.jsonRepresentation() ) storeCrashReport(report) } } } private func storeCrashReport(_ report: BLCrashReport) { var reports = loadCrashReports() reports.append(report) if reports.count > maxReports { reports = Array(reports.suffix(maxReports)) } if let data = try? JSONEncoder().encode(reports) { UserDefaults.standard.set(data, forKey: persistenceKey) } } private func loadStats() { diagnosticCount = loadCrashReports().count } } #endif