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 }