2380d37a9e
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
122 lines
4.6 KiB
Swift
122 lines
4.6 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|