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:
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user