DiagnosticsView.swift (14186B)
1 import CloudKit 2 import LinkPresentation 3 import SwiftUI 4 import UIKit 5 import UniformTypeIdentifiers 6 7 private enum TimestampTimeZone { 8 case local 9 case utc 10 } 11 12 private enum TimestampFormatter { 13 private static let localFormatter: DateFormatter = { 14 let formatter = DateFormatter() 15 formatter.dateStyle = .none 16 formatter.timeStyle = .medium 17 return formatter 18 }() 19 20 private static let utcFormatter: DateFormatter = { 21 let formatter = DateFormatter() 22 formatter.dateStyle = .none 23 formatter.timeStyle = .medium 24 formatter.dateFormat = "h:mm:ss a 'UTC'" 25 formatter.timeZone = TimeZone(secondsFromGMT: 0) 26 return formatter 27 }() 28 29 static func string(from date: Date, in timeZone: TimestampTimeZone) -> String { 30 switch timeZone { 31 case .local: 32 return localFormatter.string(from: date) 33 case .utc: 34 return utcFormatter.string(from: date) 35 } 36 } 37 } 38 39 struct DiagnosticsView: View { 40 @Environment(\.syncEngine) private var syncEngine 41 @Environment(SyncMonitor.self) private var syncMonitor 42 @Environment(EventLog.self) private var eventLog 43 44 @State private var isSyncing = false 45 @State private var isAuditingStorage = false 46 47 /// The on-screen list is a quick glance, not the archive — the buffer now 48 /// spans a full day (tens of thousands of entries during a co-solve), and 49 /// rendering it all just buries the tail. The shared file keeps everything. 50 private let visibleEventLimit = 50 51 52 var body: some View { 53 List { 54 Section("Status") { 55 row("Version", versionText) 56 row("Account Status", accountStatusText) 57 row("Engine Running", boolText(syncMonitor.snapshot?.engineRunning)) 58 row("Pending Changes", syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown") 59 row("Sharing Health", sharingHealthText) 60 row( 61 "Last Success", 62 syncMonitor.lastSuccessAt.map { TimestampFormatter.string(from: $0, in: .local) } ?? "None" 63 ) 64 row("Last Error Phase", syncMonitor.lastErrorPhase ?? "None") 65 row("Last Error Domain", syncMonitor.lastErrorDomain ?? "None") 66 row( 67 "Last Error Code", 68 syncMonitor.lastErrorCode.map(String.init) ?? "None" 69 ) 70 row("Last Error Description", syncMonitor.lastErrorDescription ?? "None") 71 } 72 73 Section("Actions") { 74 Button { 75 Task { await runFullSync() } 76 } label: { 77 HStack { 78 Text("Sync Now") 79 if isSyncing { 80 Spacer() 81 ProgressView() 82 } 83 } 84 } 85 .disabled(isSyncing || isAuditingStorage) 86 87 Button("Probe Container") { 88 Task { await probeContainer() } 89 } 90 .disabled(isSyncing || isAuditingStorage) 91 92 Button { 93 Task { await auditCloudStorage() } 94 } label: { 95 HStack { 96 Text("Audit Cloud Storage") 97 if isAuditingStorage { 98 Spacer() 99 ProgressView() 100 } 101 } 102 } 103 .disabled(isSyncing || isAuditingStorage) 104 105 Button("Reset Sync State", role: .destructive) { 106 Task { await resetSyncState() } 107 } 108 .disabled(isSyncing || isAuditingStorage) 109 } 110 111 Section { 112 if eventLog.entries.isEmpty { 113 Text("No events captured yet.") 114 .foregroundStyle(.secondary) 115 } else { 116 ForEach(eventLog.entries.suffix(visibleEventLimit).reversed()) { entry in 117 VStack(alignment: .leading, spacing: 4) { 118 Text( 119 "\(TimestampFormatter.string(from: entry.timestamp, in: .local)) [\(entry.level.uppercased())]" 120 ) 121 .font(.caption.monospaced()) 122 .foregroundStyle(.secondary) 123 124 Text(entry.message) 125 .font(.caption.monospaced()) 126 .textSelection(.enabled) 127 } 128 .padding(.vertical, 2) 129 } 130 } 131 } header: { 132 Text("Recent Events") 133 } footer: { 134 if eventLog.entries.count > visibleEventLimit { 135 Text("Showing the last \(visibleEventLimit) of \(eventLog.entries.count) events. Share Full Log includes the complete history.") 136 } 137 } 138 } 139 .navigationTitle("Diagnostics Log") 140 .navigationBarTitleDisplayMode(.inline) 141 .toolbar { 142 ToolbarItemGroup(placement: .topBarTrailing) { 143 DiagnosticsShareButton(snapshot: { diagnosticDump }) 144 } 145 } 146 .task { 147 guard let syncEngine else { return } 148 let snapshot = await syncEngine.diagnosticSnapshot() 149 syncMonitor.updateSnapshot(snapshot) 150 } 151 } 152 153 // MARK: - Actions 154 155 private func resetSyncState() async { 156 guard let syncEngine else { return } 157 await syncEngine.resetSyncState() 158 syncMonitor.note("Sync state reset (zone/subscription flags and tokens cleared)") 159 let snapshot = await syncEngine.diagnosticSnapshot() 160 syncMonitor.updateSnapshot(snapshot) 161 } 162 163 private func probeContainer() async { 164 guard let syncEngine else { return } 165 syncMonitor.note("starting container probe") 166 let results = await syncEngine.probeContainer() 167 for (name, result) in results { 168 syncMonitor.note("probe[\(name)]: \(result)") 169 } 170 syncMonitor.note("Container probe complete") 171 } 172 173 private func auditCloudStorage() async { 174 guard !isAuditingStorage, let syncEngine else { return } 175 isAuditingStorage = true 176 defer { isAuditingStorage = false } 177 178 await syncEngine.auditPrivateCloudStorage { message in 179 syncMonitor.note(message) 180 } 181 } 182 183 private func runFullSync() async { 184 guard !isSyncing, let syncEngine else { return } 185 isSyncing = true 186 defer { isSyncing = false } 187 188 await syncMonitor.run("manual fetch") { 189 try await syncEngine.fetchChanges() 190 } 191 await syncMonitor.run("manual private ping fetch") { 192 _ = try await syncEngine.fetchPushPingsDirect(scope: .private) 193 } 194 await syncMonitor.run("manual shared ping fetch") { 195 _ = try await syncEngine.fetchPushPingsDirect(scope: .shared) 196 } 197 await syncMonitor.run("manual push") { 198 try await syncEngine.pushChanges() 199 } 200 let snapshot = await syncEngine.diagnosticSnapshot() 201 syncMonitor.updateSnapshot(snapshot) 202 } 203 204 // MARK: - Subviews 205 206 @ViewBuilder 207 private func row(_ title: String, _ value: String) -> some View { 208 VStack(alignment: .leading, spacing: 4) { 209 Text(title) 210 .font(.caption) 211 .foregroundStyle(.secondary) 212 Text(value) 213 .font(.body.monospaced()) 214 .textSelection(.enabled) 215 } 216 .padding(.vertical, 2) 217 } 218 219 private var accountStatusText: String { DiagnosticsReport.accountStatusText(syncMonitor) } 220 221 private func boolText(_ value: Bool?) -> String { DiagnosticsReport.boolText(value) } 222 223 private var sharingHealthText: String { 224 guard let snapshot = syncMonitor.snapshot else { return "Unknown" } 225 guard snapshot.engineRunning, snapshot.accountStatus == .available else { 226 return "Unavailable" 227 } 228 let count = snapshot.pendingInvitationCount 229 return count == 0 230 ? "Ready" 231 : "\(count) invitation\(count == 1 ? "" : "s") waiting for CloudKit" 232 } 233 234 private var versionText: String { DiagnosticsReport.versionText } 235 236 private var diagnosticDump: DiagnosticsDump { 237 DiagnosticsReport.dump(syncMonitor: syncMonitor, eventLog: eventLog) 238 } 239 } 240 241 /// The share button, in UIKit deliberately. SwiftUI's `ShareLink` resolves its 242 /// item — for a file, render *and* disk write — before it will present the 243 /// sheet, which put a count-the-seconds stall between the tap and the menu. 244 /// `UIActivityViewController` + a promised `NSItemProvider` has the semantics 245 /// the button needs: the sheet presents instantly (the configuration's 246 /// metadata provider titles the header without loading the item), and the 247 /// promised representation is loaded on a background queue only once the user 248 /// picks an activity — the render cost lands inside the chosen activity's own 249 /// progress UI, where a wait reads as normal instead of broken. 250 /// 251 /// The button must be the UIKit view itself (a presentation anchor hidden 252 /// behind a native toolbar Button never gets installed in the window — 253 /// toolbars drop button backgrounds), so it mimics the toolbar's native 254 /// styling instead: intrinsic sizing via `sizeThatFits` and the label-colour 255 /// tint the surrounding monochrome toolbar items use. 256 private struct DiagnosticsShareButton: UIViewRepresentable { 257 /// Runs at tap time on the main actor; must stay cheap (header strings 258 /// plus a copy-on-write take of the entries buffer). 259 let snapshot: @MainActor () -> DiagnosticsDump 260 261 func makeUIView(context: Context) -> UIButton { 262 let button = UIButton(type: .system) 263 button.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal) 264 button.tintColor = .label 265 button.accessibilityLabel = "Share Full Log" 266 button.addAction( 267 UIAction { [snapshot, weak button] _ in 268 MainActor.assumeIsolated { 269 guard let button else { return } 270 let configuration = DiagnosticsShareItem.configuration(for: snapshot()) 271 let controller = UIActivityViewController(activityItemsConfiguration: configuration) 272 // iPad presents the share sheet as a popover and traps 273 // without an anchor. 274 if let popover = controller.popoverPresentationController { 275 popover.sourceView = button 276 popover.sourceRect = button.bounds 277 } 278 var presenter = button.window?.rootViewController 279 while let presented = presenter?.presentedViewController { 280 presenter = presented 281 } 282 presenter?.present(controller, animated: true) 283 } 284 }, 285 for: .touchUpInside 286 ) 287 return button 288 } 289 290 func updateUIView(_ uiView: UIButton, context: Context) {} 291 292 func sizeThatFits( 293 _ proposal: ProposedViewSize, 294 uiView: UIButton, 295 context: Context 296 ) -> CGSize? { 297 uiView.intrinsicContentSize 298 } 299 } 300 301 /// Renders the diagnostics dump only after the user picks an activity in the 302 /// share sheet — the promised data representation is not loaded before then. 303 /// The dump inputs are snapshotted at tap time, so the file reflects the log 304 /// as the user saw it. 305 /// 306 /// The item is a promise rather than a concrete `file:` URL on purpose: 307 /// Save to Files treats a raw URL item as "copy this exact file" and pins its 308 /// name, whereas a promised representation is a file the destination creates, 309 /// so the sheet's rename field works, with `suggestedName` carrying the name. 310 /// 311 /// The promise must be *data*, not a file. The share sheet fails to grant 312 /// consumers a sandbox extension for a promised file's URL (Apple DTS, 313 /// developer.apple.com/forums/thread/714354), so a promised file 314 /// representation makes Save to Files dismiss without saving and share 315 /// extensions fail to load the attachment. A data promise crosses to the 316 /// consumer directly and the consumer writes its own file, so no sandbox 317 /// handoff is involved. 318 private enum DiagnosticsShareItem { 319 private static let fileName = "crossmate-diagnostics.txt" 320 321 static func configuration(for dump: DiagnosticsDump) -> UIActivityItemsConfiguration { 322 let provider = NSItemProvider() 323 provider.suggestedName = fileName 324 // Registered as generic data, not .plainText: messaging apps classify 325 // a plain-text item as message text — Signal loads it as a string and 326 // packages an oversize-text attachment under its own name, ignoring 327 // suggestedName. A generic-data item routes through their file path, 328 // where the materialised file keeps the suggested name. The .txt in 329 // suggestedName still types the file at its destination. 330 provider.registerDataRepresentation(for: .data, visibility: .all) { completion in 331 completion(Data(dump.rendered().utf8), nil) 332 return nil 333 } 334 335 let configuration = UIActivityItemsConfiguration(itemProviders: [provider]) 336 // Without metadata the sheet has nothing to title its header with 337 // until the promise is loaded; supply it up front so presenting stays 338 // instant and the header never shows a placeholder name. 339 configuration.metadataProvider = { key in 340 switch key { 341 case .title: 342 return fileName 343 case .linkPresentationMetadata: 344 let metadata = LPLinkMetadata() 345 metadata.title = fileName 346 return metadata 347 default: 348 return nil 349 } 350 } 351 return configuration 352 } 353 }