Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions apps/swift-ios/App/Platform/NativeDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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 {
private struct Archive: Codable {
let reports: [NativeDiagnosticReport]
let clearedThrough: Date?
}

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
private var clearedThrough: Date?

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
}
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."
}
}

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 where clearedThrough.map({ report.periodEnd > $0 }) ?? true {
byID[report.id] = report
}
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 {
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."
}
}

func clear(at date: Date = .now) {
do {
// 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."
}
}
}
1 change: 1 addition & 0 deletions apps/swift-ios/App/T3CodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
101 changes: 101 additions & 0 deletions apps/swift-ios/Features/Settings/SettingsDiagnosticsView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
13 changes: 13 additions & 0 deletions apps/swift-ios/Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public struct SettingsView: View {
workspaceSection
appSection
activitySection
supportSection
aboutSection
}
.padding(.vertical, 20)
Expand Down Expand Up @@ -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")!) {
Expand Down
89 changes: 89 additions & 0 deletions apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 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) }
let file = directory.appending(path: "reports.json")
let diagnostics = NativeDiagnostics(fileURL: file)
let oldReport = report(json: "{\"old\":true}", date: 1)
diagnostics.receive([oldReport])
diagnostics.clear(at: Date(timeIntervalSince1970: 1))
#expect(diagnostics.reports.isEmpty)
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 {
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
)
}
}
Loading