Add an iCloud diagnostics screen

A new Settings > Diagnostics screen reports the ubiquity container and
account status, per-document download and eviction state, network
readiness, and a count of documents skipped by the schema-version
forward gate — surfaced to help debug why a file isn't syncing.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-08 08:03:45 -04:00
parent 653cc65e0e
commit 451abb430b
7 changed files with 1031 additions and 0 deletions
@@ -0,0 +1,120 @@
//
// DiagnosticsReport.swift
// Reference code copy into your app, adjust the type/file names to taste.
//
// Adapted from the IndieDiag package (`IndieDiag.swift` + the
// `DiagnosticReport` model, commit bc6f475), trimmed to the three
// iCloud-documents-relevant pieces (container, document sync, network)
// `cloudKitStatus` and `storageQuota` are gone along with CloudKit. This
// file shows how `ContainerDiagnostic`, `DocumentSyncInspector`, and
// `NetworkReadiness` compose into the single snapshot a settings
// "Diagnostics" screen wants plus the optional `SchemaSkipScanner`
// (a post-package addition, see that file's header) for counting
// documents skipped by the schema-version forward gate.
//
import Foundation
/// Coarse overall health, derived from the three individual statuses.
public enum DiagnosticHealth: String, Sendable {
case healthy = "Healthy"
case syncing = "Syncing"
case warning = "Warning"
case critical = "Critical"
}
/// Combined snapshot of container, document sync, and network status.
public struct DiagnosticsReport: Sendable {
public let containerStatus: ICloudContainerStatus
public let documentSync: DocumentSyncSnapshot
/// Present only when the coordinator was given a `SchemaSkipScanner`.
public let schemaSkips: SchemaSkipSnapshot?
public let networkStatus: NetworkStatus
public let generatedAt: Date
/// Note: schema-skipped documents deliberately do NOT degrade health.
/// A document written by a newer app version is an expected state, not
/// a fault the data is intact and an app update recovers it.
public var overallHealth: DiagnosticHealth {
guard containerStatus.isAvailable, networkStatus.isConnected else { return .critical }
if documentSync.errorCount > 0 || documentSync.conflictCount > 0 { return .warning }
if documentSync.isSyncing { return .syncing }
return .healthy
}
/// Human-readable summary, e.g. for a debug console or support email
/// attachment not for display as primary UI copy.
public var summary: String {
var lines = ["Diagnostics — \(overallHealth.rawValue)"]
lines.append("iCloud: \(containerStatus.isAvailable ? "available" : "unavailable") (account signed in: \(containerStatus.hasSignedInAccount))")
lines.append("Network: \(networkStatus.isConnected ? "connected via \(networkStatus.connectionType.rawValue)" : "disconnected")")
lines.append("Documents: \(documentSync.currentCount)/\(documentSync.totalCount) current")
// Skipped and evicted are paired deliberately: evicted documents
// are never probed, so the skipped count is a floor showing both
// numbers side by side lets the reader draw the conclusion.
if let schemaSkips, schemaSkips.skippedCount > 0 || documentSync.evictedCount > 0 {
lines.append("Skipped (newer schema): \(schemaSkips.skippedCount); evicted (unprobed): \(documentSync.evictedCount)")
} else if documentSync.evictedCount > 0 {
lines.append("Evicted: \(documentSync.evictedCount)")
}
if documentSync.errorCount > 0 { lines.append("Errors: \(documentSync.errorCount)") }
if documentSync.conflictCount > 0 { lines.append("Conflicts: \(documentSync.conflictCount)") }
return lines.joined(separator: "\n")
}
}
/// Coordinates the three diagnostics into a single on-demand snapshot.
/// Construct one (it's cheap no I/O happens until `generate()` runs),
/// call `generate()` when a diagnostics screen appears and again on a
/// manual "Refresh" action. Don't keep polling it on a timer and don't
/// call it from a hot path see SKILL.md's anti-patterns.
@MainActor
public final class DiagnosticsCoordinator {
private let container: ContainerDiagnostic
private let documentInspector = DocumentSyncInspector()
private let network = NetworkReadiness()
private let schemaScanner: SchemaSkipScanner?
/// Pass a `SchemaSkipScanner` to also count documents this build skips
/// because their schema version is newer than it understands:
///
/// DiagnosticsCoordinator(
/// schemaScanner: SchemaSkipScanner(
/// currentSchemaVersion: RecordDocument.currentSchema))
public init(containerIdentifier: String? = nil, schemaScanner: SchemaSkipScanner? = nil) {
self.container = ContainerDiagnostic(containerIdentifier: containerIdentifier)
self.schemaScanner = schemaScanner
}
/// Runs all checks concurrently and combines them into one report. The
/// schema scan (when configured) runs after the document snapshot it
/// depends on; container and network checks overlap with both.
public func generate() async -> DiagnosticsReport {
async let containerStatus = container.checkStatus()
async let networkStatus = network.currentStatus()
let syncSnapshot = await documentInspector.snapshot()
var schemaSkips: SchemaSkipSnapshot?
if let schemaScanner {
// Probe only documents that certainly have a full local copy
// reading an evicted (or still-downloading) document would
// force a download just to inspect one integer. Evicted
// documents therefore aren't in the skipped count; the report
// already surfaces them separately as `evictedCount`.
let locallyPresent: Set<DocumentSyncState> = [.current, .uploading, .notUploaded, .conflict]
let urls = syncSnapshot.items
.filter { locallyPresent.contains($0.syncState) }
.map(\.url)
schemaSkips = await schemaScanner.scan(urls: urls)
}
return await DiagnosticsReport(
containerStatus: containerStatus,
documentSync: syncSnapshot,
schemaSkips: schemaSkips,
networkStatus: networkStatus,
generatedAt: Date()
)
}
}