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
+174
View File
@@ -0,0 +1,174 @@
//
// DiagnosticsView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
// On-demand iCloud sync diagnostics, built on the copied indie-diag reference
// types. Reached from Settings iCloud Sync Diagnostics. Generates a fresh
// snapshot on appear and on pull-to-refresh never on a timer (see the
// indie-diag skill's anti-patterns).
//
import SwiftUI
@MainActor
@Observable
final class DiagnosticsViewModel {
/// One coordinator for the screen's lifetime; it's cheap (no I/O until
/// `generate()` runs) and each of its pieces tears itself down after each call.
/// The schema scanner resolves the forward-gate ceiling per file, because
/// Workouts has two document types with independent version counters a
/// `Splits/` file is gated by `SplitDocument.currentSchemaVersion`, a
/// `Workouts/` file by `WorkoutDocument.currentSchemaVersion`, and a `Stubs/`
/// tombstone carries no versioned payload so it's never counted as skipped.
private let coordinator = DiagnosticsCoordinator(
containerIdentifier: SyncEngine.containerIdentifier,
schemaScanner: SchemaSkipScanner(
ceilingForURL: { url in
let path = url.path
if path.contains("/Splits/") { return SplitDocument.currentSchemaVersion }
if path.contains("/Workouts/") { return WorkoutDocument.currentSchemaVersion }
return .max
},
representativeCeiling: max(
SplitDocument.currentSchemaVersion,
WorkoutDocument.currentSchemaVersion
)
)
)
private(set) var report: DiagnosticsReport?
private(set) var isRefreshing = false
func refresh() async {
isRefreshing = true
report = await coordinator.generate()
isRefreshing = false
}
}
struct DiagnosticsView: View {
@State private var viewModel = DiagnosticsViewModel()
var body: some View {
List {
if let report = viewModel.report {
overallSection(report)
iCloudSection(report)
documentsSection(report)
networkSection(report)
copySection(report)
} else if viewModel.isRefreshing {
Section { HStack { ProgressView(); Text("Gathering…").foregroundStyle(.secondary) } }
}
}
.navigationTitle("Diagnostics")
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.refresh() }
.refreshable { await viewModel.refresh() }
}
// MARK: - Sections
private func overallSection(_ report: DiagnosticsReport) -> some View {
Section("Overall") {
HStack {
Image(systemName: healthSymbol(report.overallHealth))
.foregroundStyle(healthColor(report.overallHealth))
Text(report.overallHealth.rawValue)
Spacer()
if viewModel.isRefreshing { ProgressView() }
}
}
}
private func iCloudSection(_ report: DiagnosticsReport) -> some View {
Section("iCloud") {
LabeledContent("Account", value: report.containerStatus.hasSignedInAccount ? "Signed in" : "Signed out")
LabeledContent("Container", value: report.containerStatus.isAvailable ? "Available" : "Unavailable")
}
}
@ViewBuilder
private func documentsSection(_ report: DiagnosticsReport) -> some View {
Section("Documents") {
LabeledContent("Current") {
Text("\(report.documentSync.currentCount) / \(report.documentSync.totalCount)")
}
if report.documentSync.isSyncing {
Label("Syncing…", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(.secondary)
}
// Skipped and evicted are presented as a pair: evicted documents are
// never probed, so the skipped count is a floor. Informational styling,
// not a warning color a skipped document is an expected, recoverable
// state (update the app and it opens).
if let skips = report.schemaSkips,
skips.skippedCount > 0 || report.documentSync.evictedCount > 0 {
LabeledContent("Skipped · Evicted") {
Text("\(skips.skippedCount) · \(report.documentSync.evictedCount)")
.foregroundStyle(.secondary)
}
if skips.skippedCount > 0 {
Text("Skipped documents were saved by a newer version of the app — update to open them.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else if report.documentSync.evictedCount > 0 {
LabeledContent("Evicted", value: "\(report.documentSync.evictedCount)")
}
if report.documentSync.conflictCount > 0 {
LabeledContent("Conflicts", value: "\(report.documentSync.conflictCount)")
.foregroundStyle(.orange)
}
if report.documentSync.errorCount > 0 {
LabeledContent("Errors", value: "\(report.documentSync.errorCount)")
.foregroundStyle(.orange)
}
}
}
private func networkSection(_ report: DiagnosticsReport) -> some View {
Section("Network") {
LabeledContent("Connection", value: report.networkStatus.connectionType.rawValue)
LabeledContent("Quality", value: report.networkStatus.quality.rawValue)
if report.networkStatus.isConstrained {
Label("Low Data Mode", systemImage: "exclamationmark.circle").foregroundStyle(.secondary)
}
}
}
private func copySection(_ report: DiagnosticsReport) -> some View {
Section {
Button {
UIPasteboard.general.string = report.summary
} label: {
Label("Copy Diagnostics", systemImage: "doc.on.doc")
}
} footer: {
Text("Copies a plain-text summary you can paste into a support message.")
}
}
// MARK: - Health styling
private func healthSymbol(_ health: DiagnosticHealth) -> String {
switch health {
case .healthy: "checkmark.circle.fill"
case .syncing: "arrow.triangle.2.circlepath"
case .warning: "exclamationmark.triangle.fill"
case .critical: "xmark.octagon.fill"
}
}
private func healthColor(_ health: DiagnosticHealth) -> Color {
switch health {
case .healthy: .green
case .syncing: .secondary
case .warning, .critical: .orange
}
}
}