diff --git a/apps/swift-ios/App/Platform/NativeDiagnostics.swift b/apps/swift-ios/App/Platform/NativeDiagnostics.swift new file mode 100644 index 000000000000..dba6a514d005 --- /dev/null +++ b/apps/swift-ios/App/Platform/NativeDiagnostics.swift @@ -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." + } + } +} 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..c0f718a57fbe --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/NativeDiagnosticsTests.swift @@ -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 + ) + } +}