From 24b9f3af23b518f6e8b119b0a314e6c944c75fb9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 21:23:11 -0700 Subject: [PATCH 1/3] feat(swift-ios): show native crash reports in settings --- .../App/Platform/NativeDiagnostics.swift | 128 ++++++++++++++++++ apps/swift-ios/App/T3CodeApp.swift | 1 + .../Settings/SettingsDiagnosticsView.swift | 101 ++++++++++++++ .../Features/Settings/SettingsView.swift | 13 ++ .../NativeDiagnosticsTests.swift | 65 +++++++++ 5 files changed, 308 insertions(+) create mode 100644 apps/swift-ios/App/Platform/NativeDiagnostics.swift create mode 100644 apps/swift-ios/Features/Settings/SettingsDiagnosticsView.swift create mode 100644 apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift diff --git a/apps/swift-ios/App/Platform/NativeDiagnostics.swift b/apps/swift-ios/App/Platform/NativeDiagnostics.swift new file mode 100644 index 000000000000..75b01fba6677 --- /dev/null +++ b/apps/swift-ios/App/Platform/NativeDiagnostics.swift @@ -0,0 +1,128 @@ +import CryptoKit +import Foundation +import MetricKit +import Observation + +struct NativeDiagnosticReport: Codable, Identifiable, Sendable { + static let maximumReportBytes = 256 * 1024 + + let id: String + let periodStart: Date + let periodEnd: Date + let crashCount: Int + let launchCount: Int + let json: String? + + init(data: Data, periodStart: Date, periodEnd: Date, crashCount: Int, launchCount: Int) { + id = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + self.periodStart = periodStart + self.periodEnd = periodEnd + self.crashCount = crashCount + self.launchCount = launchCount + json = data.count <= Self.maximumReportBytes ? String(data: data, encoding: .utf8) : nil + } + + var title: String { + if crashCount > 0, launchCount > 0 { return "Crash and launch report" } + return crashCount > 0 ? "Crash report" : "Launch report" + } +} + +/// MetricKit delivers these reports during normal execution, sometimes on a later launch. +/// Do not infer crashes from app lifecycle events or install crash handlers here. +@MainActor +@Observable +final class NativeDiagnostics: NSObject, MXMetricManagerSubscriber { + static let shared = NativeDiagnostics( + fileURL: URL.applicationSupportDirectory.appending(path: "Diagnostics/reports.json") + ) + static let maximumReports = 5 + static let maximumStorageBytes = 2 * 1024 * 1024 + + private(set) var reports: [NativeDiagnosticReport] = [] + private(set) var storageError: String? + private let fileURL: URL + private var started = false + + init(fileURL: URL) { + self.fileURL = fileURL + super.init() + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + do { + let file = try FileHandle(forReadingFrom: fileURL) + defer { try? file.close() } + let data = try file.read(upToCount: Self.maximumStorageBytes + 1) ?? Data() + guard data.count <= Self.maximumStorageBytes else { + storageError = "Saved reports exceed the size limit." + return + } + reports = Array(try JSONDecoder().decode([NativeDiagnosticReport].self, from: data) + .prefix(Self.maximumReports)) + } catch { + storageError = "Could not read saved reports." + } + } + + func start() { + guard !started else { return } + started = true + let manager = MXMetricManager.shared + manager.add(self) + didReceive(manager.pastDiagnosticPayloads) + } + + nonisolated func didReceive(_ payloads: [MXDiagnosticPayload]) { + // Convert on MetricKit's callback queue. Only Sendable values cross to the UI. + let reports = payloads.compactMap { payload -> NativeDiagnosticReport? in + let crashes = payload.crashDiagnostics?.count ?? 0 + let launches = payload.appLaunchDiagnostics?.count ?? 0 + guard crashes > 0 || launches > 0 else { return nil } + return NativeDiagnosticReport( + data: payload.jsonRepresentation(), + periodStart: payload.timeStampBegin, + periodEnd: payload.timeStampEnd, + crashCount: crashes, + launchCount: launches + ) + } + Task { @MainActor [weak self] in + self?.receive(reports) + } + } + + func receive(_ incoming: [NativeDiagnosticReport]) { + guard !incoming.isEmpty else { return } + var byID = Dictionary(reports.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + for report in incoming { byID[report.id] = report } + reports = Array(byID.values.sorted { + if $0.periodEnd != $1.periodEnd { return $0.periodEnd > $1.periodEnd } + return $0.id < $1.id + }.prefix(Self.maximumReports)) + do { + let data = try JSONEncoder().encode(reports) + guard data.count <= Self.maximumStorageBytes else { + storageError = "Reports exceed the size limit." + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUnlessOpen]) + storageError = nil + } catch { + storageError = "Could not save reports." + } + } + + func clear() { + do { + if FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.removeItem(at: fileURL) + } + reports = [] + storageError = nil + } catch { + storageError = "Could not clear saved reports." + } + } +} diff --git a/apps/swift-ios/App/T3CodeApp.swift b/apps/swift-ios/App/T3CodeApp.swift index d2854016003a..5d63d6d9414a 100644 --- a/apps/swift-ios/App/T3CodeApp.swift +++ b/apps/swift-ios/App/T3CodeApp.swift @@ -7,6 +7,7 @@ struct T3CodeApp: App { @State private var model: FeatureRootModel init() { + NativeDiagnostics.shared.start() let client = NativeFeatureClient() let model = FeatureRootModel(client: client) _model = State(initialValue: model) diff --git a/apps/swift-ios/Features/Settings/SettingsDiagnosticsView.swift b/apps/swift-ios/Features/Settings/SettingsDiagnosticsView.swift new file mode 100644 index 000000000000..fd825d8df0b3 --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsDiagnosticsView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct SettingsDiagnosticsView: View { + private let diagnostics = NativeDiagnostics.shared + @State private var confirmingClear = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Crash and slow-launch reports from iOS. Reports can arrive on a later launch. An empty list does not mean the app never crashed.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + + if let error = diagnostics.storageError { + Text(error).foregroundStyle(T3Colors.danger) + } + if diagnostics.reports.isEmpty { + Text("No reports received.") + .foregroundStyle(T3Colors.textSecondary) + } + ForEach(diagnostics.reports) { report in + NavigationLink { + DiagnosticReportView(report: report) + } label: { + VStack(alignment: .leading, spacing: 6) { + Text(report.title) + .font(T3Typography.threadBody) + Text(report.periodEnd.formatted(date: .abbreviated, time: .shortened)) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + Divider().overlay(T3Colors.separator) + } + if !diagnostics.reports.isEmpty || diagnostics.storageError != nil { + Button("Clear saved reports", role: .destructive) { + confirmingClear = true + } + } + } + .padding(20) + } + .foregroundStyle(T3Colors.textPrimary) + .background(T3Colors.background) + .navigationTitle("Diagnostics") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + .confirmationDialog("Clear saved reports?", isPresented: $confirmingClear) { + Button("Clear reports", role: .destructive) { diagnostics.clear() } + } message: { + Text("This removes up to five saved reports from this device.") + } + } +} + +private struct DiagnosticReportView: View { + let report: NativeDiagnosticReport + @State private var copied = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Reporting period: \(report.periodStart.formatted()) to \(report.periodEnd.formatted())") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + if let json = report.json { + Text("Review before sharing. Reports can include app exception details.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + HStack(spacing: 24) { + Button { + UIPasteboard.general.string = json + copied = true + } label: { + Label(copied ? "Copied" : "Copy report", systemImage: "doc.on.doc") + } + ShareLink(item: json) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + Text(verbatim: json) + .font(T3Typography.code) + .textSelection(.enabled) + } else { + Text("This report exceeded the 256 KB limit. Only its reporting period was saved.") + .font(T3Typography.supporting) + } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .foregroundStyle(T3Colors.textPrimary) + .background(T3Colors.background) + .navigationTitle(report.title) + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift index d5687b02f417..e543c246c643 100644 --- a/apps/swift-ios/Features/Settings/SettingsView.swift +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -17,6 +17,7 @@ public struct SettingsView: View { workspaceSection appSection activitySection + supportSection aboutSection } .padding(.vertical, 20) @@ -152,6 +153,18 @@ public struct SettingsView: View { } } + private var supportSection: some View { + SettingsSection(title: "Support") { + NavigationLink { + SettingsDiagnosticsView() + } label: { + SettingsNavigationRow(title: "Diagnostics", systemImage: "stethoscope") + } + .buttonStyle(.plain) + .accessibilityIdentifier("settings-diagnostics") + } + } + private var aboutSection: some View { SettingsSection(title: "About", footer: "Version \(appVersionLabel)") { Link(destination: URL(string: "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/pingdotgg/t3code")!) { diff --git a/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift b/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift new file mode 100644 index 000000000000..53d512941f02 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import T3Code + +@MainActor +struct NativeDiagnosticsTests { + @Test func retainsNewestReportsAndDeduplicatesRedelivery() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "reports.json") + let diagnostics = NativeDiagnostics(fileURL: file) + let reports = (0..<7).map { index in + report(json: "{\"index\":\(index)}", date: Double(index)) + } + diagnostics.receive(reports) + diagnostics.receive([reports[6]]) + let restored = NativeDiagnostics(fileURL: file) + #expect(restored.reports.count == 5) + #expect(restored.reports.first?.id == reports[6].id) + #expect(restored.reports.last?.id == reports[2].id) + #expect(restored.reports.first?.json == reports[6].json) + #expect(try Data(contentsOf: file).count < NativeDiagnostics.maximumStorageBytes) + } + + @Test func oversizedReportKeepsMetadataWithoutTruncatingJSON() { + let data = Data(repeating: 32, count: NativeDiagnosticReport.maximumReportBytes + 1) + let report = NativeDiagnosticReport( + data: data, periodStart: .distantPast, periodEnd: .distantFuture, + crashCount: 1, launchCount: 0 + ) + #expect(report.json == nil) + #expect(report.crashCount == 1) + #expect(report.periodEnd == .distantFuture) + } + + @Test func clearRemovesSavedReports() { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "reports.json") + let diagnostics = NativeDiagnostics(fileURL: file) + diagnostics.receive([report(json: "{}", date: 1)]) + diagnostics.clear() + #expect(diagnostics.reports.isEmpty) + #expect(NativeDiagnostics(fileURL: file).reports.isEmpty) + #expect(!FileManager.default.fileExists(atPath: file.path)) + } + + @Test func rejectsOversizedSavedData() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "reports.json") + try Data(repeating: 0, count: NativeDiagnostics.maximumStorageBytes + 1).write(to: file) + let diagnostics = NativeDiagnostics(fileURL: file) + #expect(diagnostics.reports.isEmpty) + #expect(diagnostics.storageError != nil) + } + + private func report(json: String, date: TimeInterval) -> NativeDiagnosticReport { + NativeDiagnosticReport( + data: Data(json.utf8), periodStart: Date(timeIntervalSince1970: date), + periodEnd: Date(timeIntervalSince1970: date), crashCount: 1, launchCount: 0 + ) + } +} From 3a3afa970c44ec7c437e1192626e80470c94f846 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 21:24:57 -0700 Subject: [PATCH 2/3] fix(swift-ios): keep cleared diagnostics from returning --- .../App/Platform/NativeDiagnostics.swift | 31 ++++++++++++++----- .../NativeDiagnosticsTests.swift | 16 +++++++--- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/apps/swift-ios/App/Platform/NativeDiagnostics.swift b/apps/swift-ios/App/Platform/NativeDiagnostics.swift index 75b01fba6677..27b4730cf810 100644 --- a/apps/swift-ios/App/Platform/NativeDiagnostics.swift +++ b/apps/swift-ios/App/Platform/NativeDiagnostics.swift @@ -33,6 +33,11 @@ struct NativeDiagnosticReport: Codable, Identifiable, Sendable { @MainActor @Observable final class NativeDiagnostics: NSObject, MXMetricManagerSubscriber { + private struct Archive: Codable { + let reports: [NativeDiagnosticReport] + let clearedThrough: Date? + } + static let shared = NativeDiagnostics( fileURL: URL.applicationSupportDirectory.appending(path: "Diagnostics/reports.json") ) @@ -43,6 +48,7 @@ final class NativeDiagnostics: NSObject, MXMetricManagerSubscriber { private(set) var storageError: String? private let fileURL: URL private var started = false + private var clearedThrough: Date? init(fileURL: URL) { self.fileURL = fileURL @@ -56,8 +62,9 @@ final class NativeDiagnostics: NSObject, MXMetricManagerSubscriber { storageError = "Saved reports exceed the size limit." return } - reports = Array(try JSONDecoder().decode([NativeDiagnosticReport].self, from: data) - .prefix(Self.maximumReports)) + let archive = try JSONDecoder().decode(Archive.self, from: data) + reports = Array(archive.reports.prefix(Self.maximumReports)) + clearedThrough = archive.clearedThrough } catch { storageError = "Could not read saved reports." } @@ -93,13 +100,15 @@ final class NativeDiagnostics: NSObject, MXMetricManagerSubscriber { func receive(_ incoming: [NativeDiagnosticReport]) { guard !incoming.isEmpty else { return } var byID = Dictionary(reports.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - for report in incoming { byID[report.id] = report } + for report in incoming where clearedThrough.map({ report.periodEnd > $0 }) ?? true { + byID[report.id] = report + } reports = Array(byID.values.sorted { if $0.periodEnd != $1.periodEnd { return $0.periodEnd > $1.periodEnd } return $0.id < $1.id }.prefix(Self.maximumReports)) do { - let data = try JSONEncoder().encode(reports) + let data = try JSONEncoder().encode(Archive(reports: reports, clearedThrough: clearedThrough)) guard data.count <= Self.maximumStorageBytes else { storageError = "Reports exceed the size limit." return @@ -114,12 +123,18 @@ final class NativeDiagnostics: NSObject, MXMetricManagerSubscriber { } } - func clear() { + func clear(at date: Date = .now) { do { - if FileManager.default.fileExists(atPath: fileURL.path) { - try FileManager.default.removeItem(at: fileURL) - } + // MetricKit can redeliver old payloads. Keep one timestamp after deletion + // so reports for cleared periods do not return on the next launch. + let cutoff = max(date, reports.map(\.periodEnd).max() ?? date, clearedThrough ?? date) + let data = try JSONEncoder().encode(Archive(reports: [], clearedThrough: cutoff)) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUnlessOpen]) reports = [] + clearedThrough = cutoff storageError = nil } catch { storageError = "Could not clear saved reports." diff --git a/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift b/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift index 53d512941f02..1de48416b8fe 100644 --- a/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift +++ b/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift @@ -33,16 +33,22 @@ struct NativeDiagnosticsTests { #expect(report.periodEnd == .distantFuture) } - @Test func clearRemovesSavedReports() { + @Test func clearSurvivesRestartAndRedeliveryWithoutDiscardingNewReports() { let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let file = directory.appending(path: "reports.json") let diagnostics = NativeDiagnostics(fileURL: file) - diagnostics.receive([report(json: "{}", date: 1)]) - diagnostics.clear() + let oldReport = report(json: "{\"old\":true}", date: 1) + diagnostics.receive([oldReport]) + diagnostics.clear(at: Date(timeIntervalSince1970: 1)) #expect(diagnostics.reports.isEmpty) - #expect(NativeDiagnostics(fileURL: file).reports.isEmpty) - #expect(!FileManager.default.fileExists(atPath: file.path)) + let restored = NativeDiagnostics(fileURL: file) + #expect(restored.reports.isEmpty) + restored.receive([oldReport]) + #expect(restored.reports.isEmpty) + let newReport = report(json: "{\"new\":true}", date: 2) + restored.receive([oldReport, newReport]) + #expect(restored.reports.map(\.id) == [newReport.id]) } @Test func rejectsOversizedSavedData() throws { From 108129448523da0ddec6cca1faf621fd72b99c37 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 21:27:47 -0700 Subject: [PATCH 3/3] fix(swift-ios): bound encoded diagnostic archive size --- .../App/Platform/NativeDiagnostics.swift | 13 ++++++++----- .../PlatformTests/NativeDiagnosticsTests.swift | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/swift-ios/App/Platform/NativeDiagnostics.swift b/apps/swift-ios/App/Platform/NativeDiagnostics.swift index 27b4730cf810..dba6a514d005 100644 --- a/apps/swift-ios/App/Platform/NativeDiagnostics.swift +++ b/apps/swift-ios/App/Platform/NativeDiagnostics.swift @@ -103,20 +103,23 @@ final class NativeDiagnostics: NSObject, MXMetricManagerSubscriber { for report in incoming where clearedThrough.map({ report.periodEnd > $0 }) ?? true { byID[report.id] = report } - reports = Array(byID.values.sorted { + var updatedReports = Array(byID.values.sorted { if $0.periodEnd != $1.periodEnd { return $0.periodEnd > $1.periodEnd } return $0.id < $1.id }.prefix(Self.maximumReports)) do { - let data = try JSONEncoder().encode(Archive(reports: reports, clearedThrough: clearedThrough)) - guard data.count <= Self.maximumStorageBytes else { - storageError = "Reports exceed the size limit." - return + var data = try JSONEncoder().encode(Archive(reports: updatedReports, clearedThrough: clearedThrough)) + // Encoding JSON as a string escapes it again. Drop older reports to + // fit the stored byte limit, as well as the report count limit. + while data.count > Self.maximumStorageBytes, !updatedReports.isEmpty { + updatedReports.removeLast() + data = try JSONEncoder().encode(Archive(reports: updatedReports, clearedThrough: clearedThrough)) } try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUnlessOpen]) + reports = updatedReports storageError = nil } catch { storageError = "Could not save reports." diff --git a/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift b/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift index 1de48416b8fe..c0f718a57fbe 100644 --- a/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift +++ b/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift @@ -33,6 +33,24 @@ struct NativeDiagnosticsTests { #expect(report.periodEnd == .distantFuture) } + @Test func encodedSizeLimitDropsOldestReportsAndKeepsMemoryInSync() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "reports.json") + let diagnostics = NativeDiagnostics(fileURL: file) + let escaped = String(repeating: "\\\"", count: 125_000) + let reports = (0..<5).map { index in + report(json: "{\"index\":\(index),\"value\":\"\(escaped)\"}", date: Double(index)) + } + diagnostics.receive(reports) + let restored = NativeDiagnostics(fileURL: file) + #expect(diagnostics.storageError == nil) + #expect(diagnostics.reports.count == 4) + #expect(diagnostics.reports.first?.id == reports[4].id) + #expect(diagnostics.reports.map(\.id) == restored.reports.map(\.id)) + #expect(try Data(contentsOf: file).count <= NativeDiagnostics.maximumStorageBytes) + } + @Test func clearSurvivesRestartAndRedeliveryWithoutDiscardingNewReports() { let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) }