crossmate

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

DiagnosticsReport.swift (4604B)


      1 import CloudKit
      2 import Foundation
      3 
      4 /// Builds the shared diagnostics dump used by both the Diagnostics screen's
      5 /// "Share Full Log" and the Invite screen's "Report Error". Keeping one builder
      6 /// means a report sent from either place carries the same header + event log.
      7 enum DiagnosticsReport {
      8     /// Standard header (version, account, sync state, last error) followed by the
      9     /// full event log. `leadingLines` are prepended verbatim so a caller can pin
     10     /// a specific failure that the sync monitor wouldn't otherwise capture (e.g.
     11     /// a share error surfaced only in the invite sheet).
     12     @MainActor
     13     static func dump(
     14         syncMonitor: SyncMonitor,
     15         eventLog: EventLog,
     16         leadingLines: [String] = []
     17     ) -> DiagnosticsDump {
     18         var lines: [String] = []
     19         if !leadingLines.isEmpty {
     20             lines.append(contentsOf: leadingLines)
     21             lines.append("")
     22         }
     23         lines.append("Version: \(versionText)")
     24         lines.append("Account Status: \(accountStatusText(syncMonitor))")
     25         lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))")
     26         lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")")
     27         lines.append("Last Success: \(syncMonitor.lastSuccessAt.map { utcFormatter.string(from: $0) } ?? "None")")
     28         lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")")
     29         lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")")
     30         lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")")
     31         lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")")
     32         lines.append("Recent Event Count: \(eventLog.entries.count)")
     33         return DiagnosticsDump(headerLines: lines, entries: eventLog.entries)
     34     }
     35 
     36     /// Marketing version and build number from the bundle. The build number is
     37     /// the commit count (set by the release script), so it pins a pasted log to
     38     /// an exact commit — the key lever for debugging what a tester is running.
     39     static var versionText: String {
     40         let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
     41         let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
     42         return "\(version) (\(build))"
     43     }
     44 
     45     @MainActor
     46     static func accountStatusText(_ syncMonitor: SyncMonitor) -> String {
     47         guard let status = syncMonitor.snapshot?.accountStatus else { return "Unknown" }
     48         switch status {
     49         case .available: return "Available"
     50         case .noAccount: return "No Account"
     51         case .restricted: return "Restricted"
     52         case .couldNotDetermine: return "Could Not Determine"
     53         case .temporarilyUnavailable: return "Temporarily Unavailable"
     54         @unknown default: return "Unknown"
     55         }
     56     }
     57 
     58     static func boolText(_ value: Bool?) -> String {
     59         guard let value else { return "Unknown" }
     60         return value ? "Yes" : "No"
     61     }
     62 
     63     private static let utcFormatter: DateFormatter = {
     64         let formatter = DateFormatter()
     65         formatter.dateStyle = .none
     66         formatter.timeStyle = .medium
     67         formatter.dateFormat = "h:mm:ss a 'UTC'"
     68         formatter.timeZone = TimeZone(secondsFromGMT: 0)
     69         return formatter
     70     }()
     71 }
     72 
     73 /// The dump's inputs, captured on the main actor so the expensive per-entry
     74 /// formatting can run off it. `EventLogEntry` is a Sendable value type, so
     75 /// taking the buffer is a cheap copy-on-write retain, not a data copy.
     76 struct DiagnosticsDump: Sendable {
     77     let headerLines: [String]
     78     let entries: [EventLogEntry]
     79 
     80     /// Formats the full dump. Runs nonisolated on purpose: tens of thousands
     81     /// of entries make this the slow part of a share, and it sits between the
     82     /// share tap and the sheet appearing — on the main actor it both stalls
     83     /// the UI and queues behind it. The formatter is created locally so the
     84     /// loop shares nothing across the isolation boundary.
     85     func rendered() -> String {
     86         let formatter = DateFormatter()
     87         formatter.dateFormat = "h:mm:ss a 'UTC'"
     88         formatter.timeZone = TimeZone(secondsFromGMT: 0)
     89 
     90         var lines = headerLines
     91         lines.append("")
     92         lines.append("Recent Events (UTC):")
     93         for entry in entries {
     94             lines.append(
     95                 "\(formatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
     96             )
     97         }
     98         return lines.joined(separator: "\n")
     99     }
    100 }