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
121 lines
5.9 KiB
Swift
121 lines
5.9 KiB
Swift
//
|
|
// 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()
|
|
)
|
|
}
|
|
}
|