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 }