crossmate

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

DiagnosticsView.swift (10304B)


      1 import CloudKit
      2 import SwiftUI
      3 
      4 struct DiagnosticsView: View {
      5     @Environment(\.syncEngine) private var syncEngine
      6     @Environment(SyncMonitor.self) private var syncMonitor
      7 
      8     @State private var isSyncing = false
      9 
     10     private static let timestampFormatter: DateFormatter = {
     11         let formatter = DateFormatter()
     12         formatter.dateStyle = .none
     13         formatter.timeStyle = .medium
     14         return formatter
     15     }()
     16 
     17     var body: some View {
     18         List {
     19             Section("Status") {
     20                 row("Account Status", accountStatusText)
     21                 row("Engine Running", boolText(syncMonitor.snapshot?.engineRunning))
     22                 row("Pending Changes", syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")
     23                 row(
     24                     "Last Success",
     25                     syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None"
     26                 )
     27                 row("Last Error Phase", syncMonitor.lastErrorPhase ?? "None")
     28                 row("Last Error Domain", syncMonitor.lastErrorDomain ?? "None")
     29                 row(
     30                     "Last Error Code",
     31                     syncMonitor.lastErrorCode.map(String.init) ?? "None"
     32                 )
     33                 row("Last Error Description", syncMonitor.lastErrorDescription ?? "None")
     34             }
     35 
     36             Section("Actions") {
     37                 Button {
     38                     Task { await runFullSync() }
     39                 } label: {
     40                     HStack {
     41                         Text("Sync Now")
     42                         if isSyncing {
     43                             Spacer()
     44                             ProgressView()
     45                         }
     46                     }
     47                 }
     48                 .disabled(isSyncing)
     49 
     50                 Button("Probe Container") {
     51                     Task { await probeContainer() }
     52                 }
     53                 .disabled(isSyncing)
     54 
     55                 Button("Reset Sync State", role: .destructive) {
     56                     Task { await resetSyncState() }
     57                 }
     58                 .disabled(isSyncing)
     59             }
     60 
     61             Section("Recent Events") {
     62                 if syncMonitor.entries.isEmpty {
     63                     Text("No events captured yet.")
     64                         .foregroundStyle(.secondary)
     65                 } else {
     66                     ForEach(syncMonitor.entries.reversed()) { entry in
     67                         VStack(alignment: .leading, spacing: 4) {
     68                             Text(
     69                                 "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())]"
     70                             )
     71                             .font(.caption.monospaced())
     72                             .foregroundStyle(.secondary)
     73 
     74                             Text(entry.message)
     75                                 .font(.caption.monospaced())
     76                                 .textSelection(.enabled)
     77                         }
     78                         .padding(.vertical, 2)
     79                     }
     80                 }
     81             }
     82         }
     83         .navigationTitle("iCloud Diagnostics")
     84         .navigationBarTitleDisplayMode(.inline)
     85         .toolbar {
     86             ToolbarItem(placement: .topBarTrailing) {
     87                 Button("Copy") {
     88                     UIPasteboard.general.string = diagnosticDump
     89                 }
     90             }
     91         }
     92         .task {
     93             guard let syncEngine else { return }
     94             let snapshot = await syncEngine.diagnosticSnapshot()
     95             syncMonitor.updateSnapshot(snapshot)
     96         }
     97     }
     98 
     99     // MARK: - Actions
    100 
    101     private func resetSyncState() async {
    102         guard let syncEngine else { return }
    103         await syncEngine.resetSyncState()
    104         syncMonitor.note("Sync state reset (zone/subscription flags and tokens cleared)")
    105         let snapshot = await syncEngine.diagnosticSnapshot()
    106         syncMonitor.updateSnapshot(snapshot)
    107     }
    108 
    109     private func probeContainer() async {
    110         guard let syncEngine else { return }
    111         syncMonitor.note("Starting container probe")
    112         let results = await syncEngine.probeContainer()
    113         for (name, result) in results {
    114             syncMonitor.note("probe[\(name)]: \(result)")
    115         }
    116         syncMonitor.note("Container probe complete")
    117     }
    118 
    119     private func runFullSync() async {
    120         guard !isSyncing, let syncEngine else { return }
    121         isSyncing = true
    122         defer { isSyncing = false }
    123 
    124         await syncMonitor.run("manual fetch") {
    125             try await syncEngine.fetchChanges()
    126         }
    127         await syncMonitor.run("manual push") {
    128             try await syncEngine.pushChanges()
    129         }
    130         let snapshot = await syncEngine.diagnosticSnapshot()
    131         syncMonitor.updateSnapshot(snapshot)
    132     }
    133 
    134     // MARK: - Subviews
    135 
    136     @ViewBuilder
    137     private func row(_ title: String, _ value: String) -> some View {
    138         VStack(alignment: .leading, spacing: 4) {
    139             Text(title)
    140                 .font(.caption)
    141                 .foregroundStyle(.secondary)
    142             Text(value)
    143                 .font(.body.monospaced())
    144                 .textSelection(.enabled)
    145         }
    146         .padding(.vertical, 2)
    147     }
    148 
    149     private var accountStatusText: String {
    150         guard let status = syncMonitor.snapshot?.accountStatus else { return "Unknown" }
    151         switch status {
    152         case .available: return "Available"
    153         case .noAccount: return "No Account"
    154         case .restricted: return "Restricted"
    155         case .couldNotDetermine: return "Could Not Determine"
    156         case .temporarilyUnavailable: return "Temporarily Unavailable"
    157         @unknown default: return "Unknown"
    158         }
    159     }
    160 
    161     private func boolText(_ value: Bool?) -> String {
    162         guard let value else { return "Unknown" }
    163         return value ? "Yes" : "No"
    164     }
    165 
    166     private var diagnosticDump: String {
    167         var lines: [String] = []
    168         lines.append("Account Status: \(accountStatusText)")
    169         lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))")
    170         lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")")
    171         lines.append(
    172             "Last Success: \(syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None")"
    173         )
    174         lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")")
    175         lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")")
    176         lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")")
    177         lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")")
    178         lines.append("")
    179         lines.append("Recent Events:")
    180         for entry in syncMonitor.entries {
    181             lines.append(
    182                 "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
    183             )
    184         }
    185         return lines.joined(separator: "\n")
    186     }
    187 }
    188 
    189 struct ShareDiagnosticsView: View {
    190     @Environment(SyncMonitor.self) private var syncMonitor
    191 
    192     private static let timestampFormatter: DateFormatter = {
    193         let formatter = DateFormatter()
    194         formatter.dateStyle = .none
    195         formatter.timeStyle = .medium
    196         return formatter
    197     }()
    198 
    199     private var shareEntries: [SyncDiagnosticEntry] {
    200         syncMonitor.entries.filter {
    201             $0.message.localizedCaseInsensitiveContains("share")
    202         }
    203     }
    204 
    205     var body: some View {
    206         List {
    207             Section("Last Share Error") {
    208                 row("Phase", syncMonitor.lastErrorPhase ?? "None")
    209                 row("Domain", syncMonitor.lastErrorDomain ?? "None")
    210                 row("Code", syncMonitor.lastErrorCode.map(String.init) ?? "None")
    211                 row("Description", syncMonitor.lastErrorDescription ?? "None")
    212             }
    213 
    214             Section("Share Events") {
    215                 if shareEntries.isEmpty {
    216                     Text("No share events captured yet.")
    217                         .foregroundStyle(.secondary)
    218                 } else {
    219                     ForEach(shareEntries.reversed()) { entry in
    220                         VStack(alignment: .leading, spacing: 4) {
    221                             Text(
    222                                 "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())]"
    223                             )
    224                             .font(.caption.monospaced())
    225                             .foregroundStyle(.secondary)
    226 
    227                             Text(entry.message)
    228                                 .font(.caption.monospaced())
    229                                 .textSelection(.enabled)
    230                         }
    231                         .padding(.vertical, 2)
    232                     }
    233                 }
    234             }
    235         }
    236         .navigationTitle("Share Diagnostics")
    237         .navigationBarTitleDisplayMode(.inline)
    238         .toolbar {
    239             ToolbarItem(placement: .topBarTrailing) {
    240                 Button("Copy") {
    241                     UIPasteboard.general.string = diagnosticDump
    242                 }
    243             }
    244         }
    245     }
    246 
    247     @ViewBuilder
    248     private func row(_ title: String, _ value: String) -> some View {
    249         VStack(alignment: .leading, spacing: 4) {
    250             Text(title)
    251                 .font(.caption)
    252                 .foregroundStyle(.secondary)
    253             Text(value)
    254                 .font(.body.monospaced())
    255                 .textSelection(.enabled)
    256         }
    257         .padding(.vertical, 2)
    258     }
    259 
    260     private var diagnosticDump: String {
    261         var lines: [String] = []
    262         lines.append("Last Share Error Phase: \(syncMonitor.lastErrorPhase ?? "None")")
    263         lines.append("Last Share Error Domain: \(syncMonitor.lastErrorDomain ?? "None")")
    264         lines.append("Last Share Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")")
    265         lines.append("Last Share Error Description: \(syncMonitor.lastErrorDescription ?? "None")")
    266         lines.append("")
    267         lines.append("Share Events:")
    268         for entry in shareEntries {
    269             lines.append(
    270                 "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
    271             )
    272         }
    273         return lines.joined(separator: "\n")
    274     }
    275 }