crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

DiagnosticsView.swift (10540B)


      1 import CloudKit
      2 import SwiftUI
      3 
      4 private enum TimestampTimeZone {
      5     case local
      6     case utc
      7 }
      8 
      9 private enum TimestampFormatter {
     10     private static let localFormatter: DateFormatter = {
     11         let formatter = DateFormatter()
     12         formatter.dateStyle = .none
     13         formatter.timeStyle = .medium
     14         return formatter
     15     }()
     16 
     17     private static let utcFormatter: DateFormatter = {
     18         let formatter = DateFormatter()
     19         formatter.dateStyle = .none
     20         formatter.timeStyle = .medium
     21         formatter.dateFormat = "h:mm:ss a 'UTC'"
     22         formatter.timeZone = TimeZone(secondsFromGMT: 0)
     23         return formatter
     24     }()
     25 
     26     static func string(from date: Date, in timeZone: TimestampTimeZone) -> String {
     27         switch timeZone {
     28         case .local:
     29             return localFormatter.string(from: date)
     30         case .utc:
     31             return utcFormatter.string(from: date)
     32         }
     33     }
     34 }
     35 
     36 struct DiagnosticsView: View {
     37     @Environment(\.syncEngine) private var syncEngine
     38     @Environment(SyncMonitor.self) private var syncMonitor
     39     @Environment(EventLog.self) private var eventLog
     40 
     41     @State private var isSyncing = false
     42     @State private var diagnosticShareFile: URL?
     43 
     44     /// The on-screen list is a quick glance, not the archive — the buffer now
     45     /// spans a full day (tens of thousands of entries during a co-solve), and
     46     /// rendering it all just buries the tail. The shared file keeps everything.
     47     private let visibleEventLimit = 50
     48 
     49     var body: some View {
     50         List {
     51             Section("Status") {
     52                 row("Version", versionText)
     53                 row("Account Status", accountStatusText)
     54                 row("Engine Running", boolText(syncMonitor.snapshot?.engineRunning))
     55                 row("Pending Changes", syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")
     56                 row(
     57                     "Last Success",
     58                     syncMonitor.lastSuccessAt.map { TimestampFormatter.string(from: $0, in: .local) } ?? "None"
     59                 )
     60                 row("Last Error Phase", syncMonitor.lastErrorPhase ?? "None")
     61                 row("Last Error Domain", syncMonitor.lastErrorDomain ?? "None")
     62                 row(
     63                     "Last Error Code",
     64                     syncMonitor.lastErrorCode.map(String.init) ?? "None"
     65                 )
     66                 row("Last Error Description", syncMonitor.lastErrorDescription ?? "None")
     67             }
     68 
     69             Section("Actions") {
     70                 Button {
     71                     Task { await runFullSync() }
     72                 } label: {
     73                     HStack {
     74                         Text("Sync Now")
     75                         if isSyncing {
     76                             Spacer()
     77                             ProgressView()
     78                         }
     79                     }
     80                 }
     81                 .disabled(isSyncing)
     82 
     83                 Button("Probe Container") {
     84                     Task { await probeContainer() }
     85                 }
     86                 .disabled(isSyncing)
     87 
     88                 Button("Reset Sync State", role: .destructive) {
     89                     Task { await resetSyncState() }
     90                 }
     91                 .disabled(isSyncing)
     92             }
     93 
     94             Section {
     95                 if eventLog.entries.isEmpty {
     96                     Text("No events captured yet.")
     97                         .foregroundStyle(.secondary)
     98                 } else {
     99                     ForEach(eventLog.entries.suffix(visibleEventLimit).reversed()) { entry in
    100                         VStack(alignment: .leading, spacing: 4) {
    101                             Text(
    102                                 "\(TimestampFormatter.string(from: entry.timestamp, in: .local)) [\(entry.level.uppercased())]"
    103                             )
    104                             .font(.caption.monospaced())
    105                             .foregroundStyle(.secondary)
    106 
    107                             Text(entry.message)
    108                                 .font(.caption.monospaced())
    109                                 .textSelection(.enabled)
    110                         }
    111                         .padding(.vertical, 2)
    112                     }
    113                 }
    114             } header: {
    115                 Text("Recent Events")
    116             } footer: {
    117                 if eventLog.entries.count > visibleEventLimit {
    118                     Text("Showing the last \(visibleEventLimit) of \(eventLog.entries.count) events. Share Full Log includes the complete history.")
    119                 }
    120             }
    121         }
    122         .navigationTitle("Diagnostics Log")
    123         .navigationBarTitleDisplayMode(.inline)
    124         .toolbar {
    125             ToolbarItemGroup(placement: .topBarTrailing) {
    126                 if let diagnosticShareFile {
    127                     ShareLink(
    128                         item: diagnosticShareFile,
    129                         preview: SharePreview(
    130                             "Crossmate Diagnostics",
    131                             image: Image(systemName: "doc.text")
    132                         )
    133                     ) {
    134                         Label("Share Full Log", systemImage: "square.and.arrow.up")
    135                     }
    136                 }
    137             }
    138         }
    139         .onAppear {
    140             refreshDiagnosticShareFile()
    141         }
    142         .onChange(of: diagnosticDump) { _, _ in
    143             refreshDiagnosticShareFile()
    144         }
    145         .task {
    146             guard let syncEngine else { return }
    147             let snapshot = await syncEngine.diagnosticSnapshot()
    148             syncMonitor.updateSnapshot(snapshot)
    149             refreshDiagnosticShareFile()
    150         }
    151     }
    152 
    153     // MARK: - Actions
    154 
    155     private func refreshDiagnosticShareFile() {
    156         do {
    157             diagnosticShareFile = try writeDiagnosticShareFile()
    158         } catch {
    159             diagnosticShareFile = nil
    160             eventLog.note("diagnostics share file failed: \(error.localizedDescription)", level: "error")
    161         }
    162     }
    163 
    164     private func writeDiagnosticShareFile() throws -> URL {
    165         let url = FileManager.default.temporaryDirectory
    166             .appendingPathComponent("crossmate-diagnostics.txt")
    167         try diagnosticDump.write(to: url, atomically: true, encoding: .utf8)
    168         return url
    169     }
    170 
    171     private func resetSyncState() async {
    172         guard let syncEngine else { return }
    173         await syncEngine.resetSyncState()
    174         syncMonitor.note("Sync state reset (zone/subscription flags and tokens cleared)")
    175         let snapshot = await syncEngine.diagnosticSnapshot()
    176         syncMonitor.updateSnapshot(snapshot)
    177     }
    178 
    179     private func probeContainer() async {
    180         guard let syncEngine else { return }
    181         syncMonitor.note("starting container probe")
    182         let results = await syncEngine.probeContainer()
    183         for (name, result) in results {
    184             syncMonitor.note("probe[\(name)]: \(result)")
    185         }
    186         syncMonitor.note("Container probe complete")
    187     }
    188 
    189     private func runFullSync() async {
    190         guard !isSyncing, let syncEngine else { return }
    191         isSyncing = true
    192         defer { isSyncing = false }
    193 
    194         await syncMonitor.run("manual fetch") {
    195             try await syncEngine.fetchChanges()
    196         }
    197         await syncMonitor.run("manual private ping fetch") {
    198             _ = try await syncEngine.fetchPushPingsDirect(scope: .private)
    199         }
    200         await syncMonitor.run("manual shared ping fetch") {
    201             _ = try await syncEngine.fetchPushPingsDirect(scope: .shared)
    202         }
    203         await syncMonitor.run("manual push") {
    204             try await syncEngine.pushChanges()
    205         }
    206         let snapshot = await syncEngine.diagnosticSnapshot()
    207         syncMonitor.updateSnapshot(snapshot)
    208     }
    209 
    210     // MARK: - Subviews
    211 
    212     @ViewBuilder
    213     private func row(_ title: String, _ value: String) -> some View {
    214         VStack(alignment: .leading, spacing: 4) {
    215             Text(title)
    216                 .font(.caption)
    217                 .foregroundStyle(.secondary)
    218             Text(value)
    219                 .font(.body.monospaced())
    220                 .textSelection(.enabled)
    221         }
    222         .padding(.vertical, 2)
    223     }
    224 
    225     private var accountStatusText: String {
    226         guard let status = syncMonitor.snapshot?.accountStatus else { return "Unknown" }
    227         switch status {
    228         case .available: return "Available"
    229         case .noAccount: return "No Account"
    230         case .restricted: return "Restricted"
    231         case .couldNotDetermine: return "Could Not Determine"
    232         case .temporarilyUnavailable: return "Temporarily Unavailable"
    233         @unknown default: return "Unknown"
    234         }
    235     }
    236 
    237     private func boolText(_ value: Bool?) -> String {
    238         guard let value else { return "Unknown" }
    239         return value ? "Yes" : "No"
    240     }
    241 
    242     /// Marketing version and build number from the bundle. The build number is
    243     /// the commit count (set by the release script), so it pins a pasted log to
    244     /// an exact commit — the key lever for debugging what a tester is running.
    245     private var versionText: String {
    246         let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
    247         let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
    248         return "\(version) (\(build))"
    249     }
    250 
    251     private var diagnosticDump: String {
    252         var lines: [String] = []
    253         lines.append("Version: \(versionText)")
    254         lines.append("Account Status: \(accountStatusText)")
    255         lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))")
    256         lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")")
    257         lines.append(
    258             "Last Success: \(syncMonitor.lastSuccessAt.map { TimestampFormatter.string(from: $0, in: .utc) } ?? "None")"
    259         )
    260         lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")")
    261         lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")")
    262         lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")")
    263         lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")")
    264         lines.append("Recent Event Count: \(eventLog.entries.count)")
    265         lines.append("")
    266         lines.append("Recent Events (UTC):")
    267         for entry in eventLog.entries {
    268             lines.append(
    269                 "\(TimestampFormatter.string(from: entry.timestamp, in: .utc)) [\(entry.level.uppercased())] \(entry.message)"
    270             )
    271         }
    272         return lines.joined(separator: "\n")
    273     }
    274 }