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:
@@ -6,6 +6,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
// On-device crash/hang diagnostics. Nothing is uploaded anywhere: reports
|
||||||
|
// stay in the app container and Apple's own pipeline is what feeds the
|
||||||
|
// Xcode Organizer. See DiagnosticsCollector.
|
||||||
|
DiagnosticsCollector.shared.start()
|
||||||
|
|
||||||
// Request notification permissions
|
// Request notification permissions
|
||||||
requestNotificationPermissions()
|
requestNotificationPermissions()
|
||||||
|
|
||||||
|
|||||||
@@ -703,3 +703,7 @@
|
|||||||
"whats_new_170_inspector_body" = "Auf breiten Bildschirmen öffnet sich das monatliche Schnellupdate in einem Seitenbereich – das Dashboard bleibt sichtbar, während du Werte eingibst.";
|
"whats_new_170_inspector_body" = "Auf breiten Bildschirmen öffnet sich das monatliche Schnellupdate in einem Seitenbereich – das Dashboard bleibt sichtbar, während du Werte eingibst.";
|
||||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID oder Optic ID";
|
"whats_new_170_biometrics_title" = "Face ID, Touch ID oder Optic ID";
|
||||||
"whats_new_170_biometrics_body" = "App-Sperre und Saldo-Privatsphäre nutzen jetzt die Biometrie deines Geräts.";
|
"whats_new_170_biometrics_body" = "App-Sperre und Saldo-Privatsphäre nutzen jetzt die Biometrie deines Geräts.";
|
||||||
|
|
||||||
|
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||||
|
"Share Diagnostics" = "Diagnose teilen";
|
||||||
|
"diagnostics_footer" = "Absturz- und Hänger-Berichte, die iOS auf diesem Gerät gesammelt hat. Sie werden nirgendwohin übertragen — nur durch Teilen verlässt ein Bericht dein iPhone.";
|
||||||
|
|||||||
@@ -704,3 +704,7 @@
|
|||||||
"whats_new_170_inspector_body" = "On wide screens the monthly Quick Update opens in a side panel, so the dashboard stays visible while you type values.";
|
"whats_new_170_inspector_body" = "On wide screens the monthly Quick Update opens in a side panel, so the dashboard stays visible while you type values.";
|
||||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID or Optic ID";
|
"whats_new_170_biometrics_title" = "Face ID, Touch ID or Optic ID";
|
||||||
"whats_new_170_biometrics_body" = "App Lock and balance privacy now use whichever biometrics your device has.";
|
"whats_new_170_biometrics_body" = "App Lock and balance privacy now use whichever biometrics your device has.";
|
||||||
|
|
||||||
|
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||||
|
"Share Diagnostics" = "Share Diagnostics";
|
||||||
|
"diagnostics_footer" = "Crash and hang reports collected by iOS on this device. They are never uploaded anywhere — sharing one is the only way it leaves your iPhone.";
|
||||||
|
|||||||
@@ -657,3 +657,7 @@
|
|||||||
"whats_new_170_inspector_body" = "En pantallas anchas la actualización rápida mensual se abre en un panel lateral: el panel principal sigue visible mientras escribes valores.";
|
"whats_new_170_inspector_body" = "En pantallas anchas la actualización rápida mensual se abre en un panel lateral: el panel principal sigue visible mientras escribes valores.";
|
||||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID u Optic ID";
|
"whats_new_170_biometrics_title" = "Face ID, Touch ID u Optic ID";
|
||||||
"whats_new_170_biometrics_body" = "El bloqueo de la app y la privacidad de saldos usan la biometría que tenga tu dispositivo.";
|
"whats_new_170_biometrics_body" = "El bloqueo de la app y la privacidad de saldos usan la biometría que tenga tu dispositivo.";
|
||||||
|
|
||||||
|
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||||
|
"Share Diagnostics" = "Compartir diagnósticos";
|
||||||
|
"diagnostics_footer" = "Informes de fallos y bloqueos que iOS ha recogido en este dispositivo. Nunca se envían a ningún sitio: compartir uno es la única forma de que salga de tu iPhone.";
|
||||||
|
|||||||
@@ -703,3 +703,7 @@
|
|||||||
"whats_new_170_inspector_body" = "Sur les grands écrans, la mise à jour rapide mensuelle s'ouvre dans un panneau latéral : le tableau de bord reste visible pendant la saisie.";
|
"whats_new_170_inspector_body" = "Sur les grands écrans, la mise à jour rapide mensuelle s'ouvre dans un panneau latéral : le tableau de bord reste visible pendant la saisie.";
|
||||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
|
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
|
||||||
"whats_new_170_biometrics_body" = "Le verrouillage de l'app et la confidentialité des soldes utilisent la biométrie de votre appareil.";
|
"whats_new_170_biometrics_body" = "Le verrouillage de l'app et la confidentialité des soldes utilisent la biométrie de votre appareil.";
|
||||||
|
|
||||||
|
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||||
|
"Share Diagnostics" = "Partager les diagnostics";
|
||||||
|
"diagnostics_footer" = "Rapports de plantage et de blocage collectés par iOS sur cet appareil. Ils ne sont jamais envoyés nulle part : les partager est le seul moyen qu'ils quittent votre iPhone.";
|
||||||
|
|||||||
@@ -703,3 +703,7 @@
|
|||||||
"whats_new_170_inspector_body" = "Sugli schermi ampi l'aggiornamento rapido mensile si apre in un pannello laterale: la dashboard resta visibile mentre inserisci i valori.";
|
"whats_new_170_inspector_body" = "Sugli schermi ampi l'aggiornamento rapido mensile si apre in un pannello laterale: la dashboard resta visibile mentre inserisci i valori.";
|
||||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID o Optic ID";
|
"whats_new_170_biometrics_title" = "Face ID, Touch ID o Optic ID";
|
||||||
"whats_new_170_biometrics_body" = "Blocco app e privacy dei saldi usano la biometria disponibile sul tuo dispositivo.";
|
"whats_new_170_biometrics_body" = "Blocco app e privacy dei saldi usano la biometria disponibile sul tuo dispositivo.";
|
||||||
|
|
||||||
|
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||||
|
"Share Diagnostics" = "Condividi diagnostica";
|
||||||
|
"diagnostics_footer" = "Rapporti di crash e blocchi raccolti da iOS su questo dispositivo. Non vengono mai inviati da nessuna parte: condividerne uno è l'unico modo per farlo uscire dal tuo iPhone.";
|
||||||
|
|||||||
@@ -703,3 +703,7 @@
|
|||||||
"whats_new_170_inspector_body" = "広い画面では月次のクイック更新がサイドパネルで開き、値を入力している間もダッシュボードが見えたままです。";
|
"whats_new_170_inspector_body" = "広い画面では月次のクイック更新がサイドパネルで開き、値を入力している間もダッシュボードが見えたままです。";
|
||||||
"whats_new_170_biometrics_title" = "Face ID、Touch ID、Optic ID";
|
"whats_new_170_biometrics_title" = "Face ID、Touch ID、Optic ID";
|
||||||
"whats_new_170_biometrics_body" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。";
|
"whats_new_170_biometrics_body" = "アプリロックと残高のプライバシーが、デバイスの生体認証の種類に合わせて動作します。";
|
||||||
|
|
||||||
|
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||||
|
"Share Diagnostics" = "診断情報を共有";
|
||||||
|
"diagnostics_footer" = "iOS がこの端末で収集したクラッシュとハングのレポートです。どこにも送信されません。共有した場合だけ iPhone の外に出ます。";
|
||||||
|
|||||||
@@ -703,3 +703,7 @@
|
|||||||
"whats_new_170_inspector_body" = "Em telas largas a atualização rápida mensal abre em um painel lateral: o painel principal continua visível enquanto você digita valores.";
|
"whats_new_170_inspector_body" = "Em telas largas a atualização rápida mensal abre em um painel lateral: o painel principal continua visível enquanto você digita valores.";
|
||||||
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
|
"whats_new_170_biometrics_title" = "Face ID, Touch ID ou Optic ID";
|
||||||
"whats_new_170_biometrics_body" = "O bloqueio do app e a privacidade de saldos usam a biometria disponível no seu aparelho.";
|
"whats_new_170_biometrics_body" = "O bloqueio do app e a privacidade de saldos usam a biometria disponível no seu aparelho.";
|
||||||
|
|
||||||
|
/* 1.7.0 privacy cleanup: on-device diagnostics + honest backup wording */
|
||||||
|
"Share Diagnostics" = "Compartilhar diagnósticos";
|
||||||
|
"diagnostics_footer" = "Relatórios de falhas e travamentos coletados pelo iOS neste dispositivo. Nunca são enviados para lugar nenhum: compartilhar um é a única forma de ele sair do seu iPhone.";
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -881,10 +881,34 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.yellow)
|
.foregroundStyle(.yellow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only shown when MetricKit has actually left something behind.
|
||||||
|
// Sharing is the only way a report leaves the device.
|
||||||
|
if let latestReport = diagnosticReports.first {
|
||||||
|
Button {
|
||||||
|
viewModel.shareItem = SettingsViewModel.ShareItem(url: latestReport)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Share Diagnostics")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("About")
|
Text("About")
|
||||||
|
} footer: {
|
||||||
|
if !diagnosticReports.isEmpty {
|
||||||
|
Text("diagnostics_footer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crash/hang reports MetricKit left in the app container. They never leave
|
||||||
|
/// the device unless the user shares one from here.
|
||||||
|
private var diagnosticReports: [URL] { DiagnosticsCollector.shared.storedReports() }
|
||||||
|
|
||||||
// MARK: - Danger Zone Section
|
// MARK: - Danger Zone Section
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user