sustituir Crashlytics por un recolector MetricKit que no sale del dispositivo
FirebaseCrashlytics se llevaba los informes de fallo a Google. Los crashes ya llegan a Xcode Organizer por la vía de Apple, así que el sustituto no tiene que enviar nada: DiagnosticsCollector se suscribe a MXMetricManager, guarda los payloads de crash, cuelgue y escritura en disco como JSON en Application Support/Diagnostics y ahí se quedan. - Sin código de red. Retención de 5 informes, los ficheros se marcan como excluidos de backup (son ayuda de depuración, no datos del usuario). - Arranque: AppDelegate llama a DiagnosticsCollector.shared.start() en lugar de FirebaseApp.configure() + MobileAds.shared.start(). - Ajustes → Acerca de: "Compartir diagnósticos", visible solo cuando hay algo recogido. Compartir es la única forma de que un informe salga del iPhone, y la decide el usuario. Cadenas nuevas en los 7 idiomas. Un recolector que guarda y no enseña sería código muerto, y uno que sube sería el problema de antes con otro nombre; por eso guarda en local y deja el envío como acción explícita. Closes #51 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user