crossmate

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

DiagnosticsReport.swift (4764B)


      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(
     28             "Pending Invitations: " +
     29             (syncMonitor.snapshot.map { String($0.pendingInvitationCount) } ?? "Unknown")
     30         )
     31         lines.append("Last Success: \(syncMonitor.lastSuccessAt.map { utcFormatter.string(from: $0) } ?? "None")")
     32         lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")")
     33         lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")")
     34         lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")")
     35         lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")")
     36         lines.append("Recent Event Count: \(eventLog.entries.count)")
     37         return DiagnosticsDump(headerLines: lines, entries: eventLog.entries)
     38     }
     39 
     40     /// Marketing version and build number from the bundle. The build number is
     41     /// the commit count (set by the release script), so it pins a pasted log to
     42     /// an exact commit — the key lever for debugging what a tester is running.
     43     static var versionText: String {
     44         let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
     45         let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
     46         return "\(version) (\(build))"
     47     }
     48 
     49     @MainActor
     50     static func accountStatusText(_ syncMonitor: SyncMonitor) -> String {
     51         guard let status = syncMonitor.snapshot?.accountStatus else { return "Unknown" }
     52         switch status {
     53         case .available: return "Available"
     54         case .noAccount: return "No Account"
     55         case .restricted: return "Restricted"
     56         case .couldNotDetermine: return "Could Not Determine"
     57         case .temporarilyUnavailable: return "Temporarily Unavailable"
     58         @unknown default: return "Unknown"
     59         }
     60     }
     61 
     62     static func boolText(_ value: Bool?) -> String {
     63         guard let value else { return "Unknown" }
     64         return value ? "Yes" : "No"
     65     }
     66 
     67     private static let utcFormatter: DateFormatter = {
     68         let formatter = DateFormatter()
     69         formatter.dateStyle = .none
     70         formatter.timeStyle = .medium
     71         formatter.dateFormat = "h:mm:ss a 'UTC'"
     72         formatter.timeZone = TimeZone(secondsFromGMT: 0)
     73         return formatter
     74     }()
     75 }
     76 
     77 /// The dump's inputs, captured on the main actor so the expensive per-entry
     78 /// formatting can run off it. `EventLogEntry` is a Sendable value type, so
     79 /// taking the buffer is a cheap copy-on-write retain, not a data copy.
     80 struct DiagnosticsDump: Sendable {
     81     let headerLines: [String]
     82     let entries: [EventLogEntry]
     83 
     84     /// Formats the full dump. Runs nonisolated on purpose: tens of thousands
     85     /// of entries make this the slow part of a share, and it sits between the
     86     /// share tap and the sheet appearing — on the main actor it both stalls
     87     /// the UI and queues behind it. The formatter is created locally so the
     88     /// loop shares nothing across the isolation boundary.
     89     func rendered() -> String {
     90         let formatter = DateFormatter()
     91         formatter.dateFormat = "h:mm:ss a 'UTC'"
     92         formatter.timeZone = TimeZone(secondsFromGMT: 0)
     93 
     94         var lines = headerLines
     95         lines.append("")
     96         lines.append("Recent Events (UTC):")
     97         for entry in entries {
     98             lines.append(
     99                 "\(formatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
    100             )
    101         }
    102         return lines.joined(separator: "\n")
    103     }
    104 }