import Foundation import MetricKit import os /// On-device replacement for Crashlytics. /// /// `MXMetricManager` hands the app a daily payload with crash, hang and /// disk-write diagnostics that Apple already collected for it. This collector /// writes those payloads to the app container as JSON and **never sends them /// anywhere** — no network code, no third-party SDK, no identifiers. Crash /// reports still reach Xcode Organizer through Apple's own pipeline, which is /// the channel that was actually useful about Crashlytics. /// /// The user can share the stored reports from Settings; that is the only way /// one ever leaves the device, and it is an explicit action. final class DiagnosticsCollector: NSObject { static let shared = DiagnosticsCollector() /// Reports older than this are pruned on every new payload. private static let retentionCount = 5 private let logger = Logger(subsystem: AppConstants.bundleIdentifier, category: "diagnostics") private let fileManager = FileManager.default private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd-HHmmss" formatter.locale = Locale(identifier: "en_US_POSIX") return formatter }() private override init() { super.init() } /// Subscribes to MetricKit. Safe to call more than once. func start() { MXMetricManager.shared.add(self) } // MARK: - Storage /// `Application Support/Diagnostics`, created on demand. private var directory: URL? { guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let url = base.appendingPathComponent("Diagnostics", isDirectory: true) if !fileManager.fileExists(atPath: url.path) { try? fileManager.createDirectory(at: url, withIntermediateDirectories: true) } return url } /// Stored reports, newest first. Used by Settings to offer sharing them. func storedReports() -> [URL] { guard let directory else { return [] } let contents = (try? fileManager.contentsOfDirectory( at: directory, includingPropertiesForKeys: [.contentModificationDateKey] )) ?? [] return contents .filter { $0.pathExtension == "json" } .sorted { lhs, rhs in let l = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast let r = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast return l > r } } func deleteStoredReports() { for url in storedReports() { try? fileManager.removeItem(at: url) } } private func write(_ data: Data, prefix: String) { guard let directory else { return } let name = "\(prefix)-\(dateFormatter.string(from: Date())).json" let url = directory.appendingPathComponent(name) do { try data.write(to: url, options: .atomic) // Diagnostics are debugging aids, not user data: keep them out of // iCloud/iTunes backups. var resourceValues = URLResourceValues() resourceValues.isExcludedFromBackup = true var mutableURL = url try? mutableURL.setResourceValues(resourceValues) logger.info("Stored diagnostic report \(name, privacy: .public)") } catch { logger.error("Could not store diagnostic report: \(error.localizedDescription, privacy: .public)") } prune() } private func prune() { let reports = storedReports() guard reports.count > Self.retentionCount else { return } for url in reports.dropFirst(Self.retentionCount) { try? fileManager.removeItem(at: url) } } } // MARK: - MXMetricManagerSubscriber extension DiagnosticsCollector: MXMetricManagerSubscriber { func didReceive(_ payloads: [MXMetricPayload]) { for payload in payloads { write(payload.jsonRepresentation(), prefix: "metrics") } } func didReceive(_ payloads: [MXDiagnosticPayload]) { for payload in payloads { let crashes = payload.crashDiagnostics?.count ?? 0 let hangs = payload.hangDiagnostics?.count ?? 0 logger.info("Diagnostic payload: \(crashes, privacy: .public) crash(es), \(hangs, privacy: .public) hang(s)") write(payload.jsonRepresentation(), prefix: "diagnostics") } } }