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
+141
View File
@@ -0,0 +1,141 @@
//
// ContainerStatus.swift
// Reference code copy into your app, adjust the type/file names to taste.
//
// Adapted from the IndieDiag package (`iCloudContainerDiagnostic.swift`,
// commit bc6f475). The original resolved account status via
// `CKContainer.accountStatus`, which pulls in CloudKit for a fact
// `FileManager` already knows: `ubiquityIdentityToken` is non-nil exactly
// when an iCloud account is signed in, checked synchronously with no
// network round trip and no CloudKit entitlement required. That swap is
// the whole reason this file has no `import CloudKit`.
//
// Trade-off: `ubiquityIdentityToken` only distinguishes "signed in" from
// "not signed in." It cannot tell you *why* an account is unavailable
// (restricted, temporarily unavailable, etc.) the way CloudKit's richer
// `CKAccountStatus` can that granularity isn't worth reintroducing
// CloudKit for a documents-only app. If you need it, it means you've
// reintroduced CloudKit somewhere else too.
//
import Foundation
/// Availability and configuration of the app's iCloud ubiquity container.
public struct ICloudContainerStatus: Sendable {
public let isAvailable: Bool
public let containerURL: URL?
public let containerIdentifier: String?
public let hasSignedInAccount: Bool
public let lastChecked: Date
public init(
isAvailable: Bool,
containerURL: URL?,
containerIdentifier: String?,
hasSignedInAccount: Bool,
lastChecked: Date = Date()
) {
self.isAvailable = isAvailable
self.containerURL = containerURL
self.containerIdentifier = containerIdentifier
self.hasSignedInAccount = hasSignedInAccount
self.lastChecked = lastChecked
}
}
/// Point-in-time checks for the iCloud ubiquity container: is an account
/// signed in, and can the container's URL be resolved. An `actor` because
/// `url(forUbiquityContainerIdentifier:)` is a blocking FileManager call
/// that can stall indefinitely when the account is in a bad state (password
/// reverification pending is the classic case per the icloud-sync-engine
/// skill) actor isolation keeps that stall off whichever thread awaits it,
/// in practice the main thread.
///
/// This does point-in-time checks, not continuous monitoring. Call it when
/// a diagnostics screen appears or the user taps "Refresh" see
/// SKILL.md's anti-patterns for why this shouldn't run on a timer.
public actor ContainerDiagnostic {
private let containerIdentifier: String?
/// - Parameter containerIdentifier: iCloud container identifier. Pass
/// `nil` to use the app's default (single) ubiquity container.
public init(containerIdentifier: String? = nil) {
self.containerIdentifier = containerIdentifier
}
/// Full status snapshot: signed-in check + container URL resolution.
public func checkStatus() async -> ICloudContainerStatus {
let hasAccount = FileManager.default.ubiquityIdentityToken != nil
let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: containerIdentifier)
return ICloudContainerStatus(
isAvailable: hasAccount && containerURL != nil,
containerURL: containerURL,
containerIdentifier: containerIdentifier,
hasSignedInAccount: hasAccount
)
}
/// Convenience for a plain yes/no check.
public func isAvailable() async -> Bool {
await checkStatus().isAvailable
}
/// The `Documents/` subdirectory inside the container, creating it if
/// needed. Returns `nil` when the container can't be resolved treat
/// that as "iCloud required," not as a cue to fall back to local
/// storage (see the icloud-sync-engine skill).
public func documentsURL() async -> URL? {
guard let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: containerIdentifier) else {
return nil
}
let documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true)
try? FileManager.default.createDirectory(at: documentsURL, withIntermediateDirectories: true)
return documentsURL
}
/// File count and total size under `Documents/`, for a diagnostics
/// screen's "N documents, X MB" line. Walks the whole tree fine for
/// typical per-user datasets, but it's an O(files) directory walk, not
/// something to call in a loop.
public func containerStats() async -> (fileCount: Int, totalBytes: Int64)? {
guard let documentsURL = await documentsURL() else { return nil }
return Self.walk(documentsURL)
}
/// Synchronous, non-`async` on purpose: `FileManager.DirectoryEnumerator`
/// iteration goes through `NSEnumerator.nextObject()`, which Foundation
/// marks unavailable directly inside an `async` function body. Keeping
/// the `for case let ... in enumerator` loop in a plain sync function
/// and calling it from the `async` method above sidesteps that
/// the blocking work still happens off the caller's thread because this
/// whole type is an actor.
private nonisolated static func walk(_ documentsURL: URL) -> (fileCount: Int, totalBytes: Int64) {
// No `.skipsHiddenFiles`: an evicted document exists on disk only as a
// hidden ".<name>.icloud" placeholder skipping hidden files makes
// evicted documents vanish from the count (a fully-evicted container
// would report "0 documents"). Placeholders count as documents; their
// on-disk placeholder size stands in for the real size, so totalBytes
// reflects local footprint, not logical size. Other hidden files
// (.DS_Store etc.) are still skipped by name.
guard let enumerator = FileManager.default.enumerator(
at: documentsURL,
includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey],
options: []
) else { return (0, 0) }
var fileCount = 0
var totalBytes: Int64 = 0
for case let fileURL as URL in enumerator {
guard let values = try? fileURL.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]),
values.isDirectory == false else { continue }
if fileURL.lastPathComponent.hasPrefix("."), fileURL.pathExtension != "icloud" { continue }
fileCount += 1
totalBytes += Int64(values.fileSize ?? 0)
}
return (fileCount, totalBytes)
}
}
@@ -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()
)
}
}
+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
}
}
}
@@ -0,0 +1,246 @@
//
// DocumentSyncInspector.swift
// Reference code copy into your app, adjust the type/file names to taste.
//
// Adapted from the IndieDiag package (`iCloudDocumentMonitor.swift`,
// commit bc6f475). Two changes from the original:
//
// 1. One-shot, not always-on. The original ran a persistent
// `NSMetadataQuery` for the app's whole lifetime and pushed updates
// through Combine publishers. That's the icloud-sync-engine skill's
// job (see `MetadataObserver`/`ICloudFileMonitor` there), which already
// owns a query against the same scope to drive the local cache. This
// type instead starts a *second*, deliberately short-lived query only
// when a diagnostics screen wants a fresh read, and stops it once the
// first gather completes no ongoing duplicate query competing with
// the sync engine's.
//
// 2. `@MainActor`, not `actor`. `NSMetadataQuery` must be started and read
// on the main thread the original's plain `actor` didn't actually
// guarantee that. `@MainActor` makes it a compile-time property instead
// of a convention (see the icloud-sync-engine skill's monitor
// reference for the same rationale).
//
// Combine publishers are gone in favor of a plain `async` snapshot a
// diagnostics screen wants "the current state," not a running feed.
//
import Foundation
/// Sync state for one document, as reported by `NSMetadataQuery`.
public enum DocumentSyncState: String, Sendable {
case current = "Current"
case uploading = "Uploading"
case downloading = "Downloading"
case notUploaded = "Not Uploaded"
case evicted = "Evicted"
case conflict = "Conflict"
case error = "Error"
case unknown = "Unknown"
public var isActive: Bool { self == .uploading || self == .downloading }
public var needsAttention: Bool { self == .error || self == .conflict }
}
/// A document sync/access error surfaced by `NSMetadataQuery`.
public struct DocumentSyncError: Sendable, Identifiable {
public let id = UUID()
public let url: URL
public let fileName: String
public let errorDescription: String
public let timestamp: Date
}
/// Detailed status for one document.
public struct DocumentItemStatus: Sendable, Identifiable {
public var id: URL { url }
public let url: URL
public let fileName: String
public let syncState: DocumentSyncState
public let uploadProgress: Double?
public let downloadProgress: Double?
public let error: DocumentSyncError?
public let hasUnresolvedConflicts: Bool
public let lastModified: Date?
public let fileSize: Int64?
}
/// A point-in-time snapshot of every document's sync state.
public struct DocumentSyncSnapshot: Sendable {
public let items: [DocumentItemStatus]
public let generatedAt: Date
public var totalCount: Int { items.count }
public var currentCount: Int { items.filter { $0.syncState == .current }.count }
public var evictedCount: Int { items.filter { $0.syncState == .evicted }.count }
public var errorCount: Int { items.filter { $0.syncState == .error }.count }
public var conflictCount: Int { items.filter { $0.hasUnresolvedConflicts }.count }
public var isSyncing: Bool { items.contains { $0.syncState.isActive } }
public var errors: [DocumentSyncError] { items.compactMap(\.error) }
}
/// One-shot inspector for per-document iCloud sync/eviction state. Meant to
/// be spun up when a diagnostics screen appears and torn down (deallocated)
/// when it disappears not a second, continuously-running query living
/// alongside the sync engine's own `NSMetadataQuery`.
@MainActor
public final class DocumentSyncInspector {
private var query: NSMetadataQuery?
public init() {}
/// Runs a query against the Documents/ scope of the ubiquity container,
/// waits for the initial gather to complete (or for `timeout` to
/// elapse, whichever comes first), captures a snapshot, and stops the
/// query. Call this fresh each time the diagnostics screen wants an
/// update don't leave it running and don't call it on a timer.
///
/// The timeout guards against a wedged iCloud account (the same
/// "reverification pending" state that can hang plain file operations)
/// leaving the query gathering forever; on timeout this returns an
/// empty snapshot rather than hanging the caller.
public func snapshot(
predicate: NSPredicate = NSPredicate(value: true),
timeout: Duration = .seconds(10)
) async -> DocumentSyncSnapshot {
stop()
let query = NSMetadataQuery()
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
query.predicate = predicate
self.query = query
// Filter by identity ourselves rather than passing `query` as the
// `object:` argument: `NSMetadataQuery` is explicitly not
// `Sendable`, and this async sequence's `object:` parameter
// requires it. Filtering after the fact keeps us out of another
// query's gather-complete notification (e.g. the sync engine's own
// long-lived `NSMetadataQuery`, if the app also uses the
// icloud-sync-engine skill) without smuggling a non-Sendable type
// across the sequence's internal boundary.
let gatherComplete = NotificationCenter.default.notifications(named: .NSMetadataQueryDidFinishGathering)
// Precompute our query's identity as a `Sendable` `ObjectIdentifier` so the
// off-actor gather task can pick our notification out of the stream WITHOUT
// sending the notification's non-`Sendable` `object` (`Any?`) across the
// actor boundary the app builds with `SWIFT_STRICT_CONCURRENCY: complete`,
// which rejects that hop. (The reference file compared via an `await
// self.isCurrentQuery(...)` call; that sends `Any?` to the main actor.)
let queryID = ObjectIdentifier(query)
query.start()
let didGather = await withTaskGroup(of: Bool.self) { group in
group.addTask {
let iterator = gatherComplete.makeAsyncIterator()
while let notification = await iterator.next() {
guard let object = notification.object,
ObjectIdentifier(object as AnyObject) == queryID else { continue }
return true
}
return false
}
group.addTask {
try? await Task.sleep(for: timeout)
return false
}
let first = await group.next() ?? false
group.cancelAll()
return first
}
defer { stop() }
guard didGather, let query = self.query else {
return DocumentSyncSnapshot(items: [], generatedAt: Date())
}
query.disableUpdates()
let items = (query.results as? [NSMetadataItem] ?? []).map(Self.extractStatus)
query.enableUpdates()
return DocumentSyncSnapshot(items: items, generatedAt: Date())
}
/// Status for a single document, from a fresh snapshot. For "is this
/// one attachment still syncing?" rather than a whole-container read.
public func status(for url: URL) async -> DocumentItemStatus? {
await snapshot().items.first { $0.url == url }
}
private func stop() {
query?.stop()
query = nil
}
private static func extractStatus(from item: NSMetadataItem) -> DocumentItemStatus {
guard let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL else {
return DocumentItemStatus(
url: URL(fileURLWithPath: "/unknown"),
fileName: "Unknown",
syncState: .unknown,
uploadProgress: nil,
downloadProgress: nil,
error: nil,
hasUnresolvedConflicts: false,
lastModified: nil,
fileSize: nil
)
}
let fileName = item.value(forAttribute: NSMetadataItemFSNameKey) as? String ?? url.lastPathComponent
let isUploaded = item.value(forAttribute: NSMetadataUbiquitousItemIsUploadedKey) as? Bool ?? false
let isUploading = item.value(forAttribute: NSMetadataUbiquitousItemIsUploadingKey) as? Bool ?? false
let isDownloading = item.value(forAttribute: NSMetadataUbiquitousItemIsDownloadingKey) as? Bool ?? false
let uploadError = item.value(forAttribute: NSMetadataUbiquitousItemUploadingErrorKey) as? NSError
let downloadError = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingErrorKey) as? NSError
let hasConflicts = item.value(forAttribute: NSMetadataUbiquitousItemHasUnresolvedConflictsKey) as? Bool ?? false
// Evicted = exists in cloud, no local copy. Distinct from "not
// uploaded yet," which is the reverse (local copy, not in cloud yet).
let downloadStatus = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
let isEvicted = downloadStatus == NSMetadataUbiquitousItemDownloadingStatusNotDownloaded
let uploadProgress = item.value(forAttribute: NSMetadataUbiquitousItemPercentUploadedKey) as? Double
let downloadProgress = item.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? Double
let lastModified = item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date
let fileSize = item.value(forAttribute: NSMetadataItemFSSizeKey) as? Int64
let syncState: DocumentSyncState
if uploadError != nil || downloadError != nil {
syncState = .error
} else if hasConflicts {
syncState = .conflict
} else if isEvicted {
syncState = .evicted
} else if isUploading {
syncState = .uploading
} else if isDownloading {
syncState = .downloading
} else if isUploaded {
syncState = .current
} else {
syncState = .notUploaded
}
let error: DocumentSyncError? = (uploadError ?? downloadError).map {
DocumentSyncError(
url: url,
fileName: fileName,
errorDescription: $0.localizedDescription,
timestamp: Date()
)
}
return DocumentItemStatus(
url: url,
fileName: fileName,
syncState: syncState,
uploadProgress: uploadProgress,
downloadProgress: downloadProgress,
error: error,
hasUnresolvedConflicts: hasConflicts,
lastModified: lastModified,
fileSize: fileSize
)
}
}
+164
View File
@@ -0,0 +1,164 @@
//
// NetworkReadiness.swift
// Reference code copy into your app, adjust the type/file names to taste.
//
// Adapted from the IndieDiag package (`NetworkDiagnostic.swift`,
// commit bc6f475). Trimmed to the NWPathMonitor-based status/quality
// estimate only dropped the original's `testCloudConnectivity()` HTTP
// probe to icloud.com as extra surface a documents-only app doesn't need;
// `NWPathMonitor`'s path status is enough to decide "is this a reasonable
// moment to sync." Combine publishers replaced with a plain `async`
// one-shot read plus an `AsyncStream` for the rare screen that wants live
// updates while visible.
//
import Foundation
import Network
/// Network connection type.
public enum ConnectionType: String, Sendable {
case wifi = "Wi-Fi"
case cellular = "Cellular"
case ethernet = "Ethernet"
case unknown = "Unknown"
case none = "No Connection"
}
/// Network quality estimate for syncing.
public enum NetworkQuality: String, Sendable {
case excellent = "Excellent"
case good = "Good"
case fair = "Fair"
case poor = "Poor"
case none = "No Connection"
/// Suitable for large file uploads (e.g. media attachments).
public var canSyncLargeFiles: Bool { self == .excellent || self == .good }
/// Suitable for basic sync operations (small JSON documents).
public var canSync: Bool { self != .none }
}
/// Network connectivity status relevant to iCloud sync.
public struct NetworkStatus: Sendable {
public let isConnected: Bool
public let connectionType: ConnectionType
public let isExpensive: Bool
public let isConstrained: Bool
public let supportsCloudSync: Bool
public let lastChecked: Date
public init(
isConnected: Bool,
connectionType: ConnectionType,
isExpensive: Bool = false,
isConstrained: Bool = false,
supportsCloudSync: Bool = true,
lastChecked: Date = Date()
) {
self.isConnected = isConnected
self.connectionType = connectionType
self.isExpensive = isExpensive
self.isConstrained = isConstrained
self.supportsCloudSync = supportsCloudSync
self.lastChecked = lastChecked
}
public var quality: NetworkQuality {
guard isConnected else { return .none }
if isConstrained { return .poor }
switch connectionType {
case .wifi, .ethernet: return .excellent
case .cellular: return isExpensive ? .fair : .good
case .unknown, .none: return .poor
}
}
}
/// `NWPathMonitor`-based network readiness check. Two ways to use it:
///
/// - `currentStatus()` a one-shot read for a diagnostics screen's initial
/// state, or before starting a sync operation.
/// - `statusUpdates()` an `AsyncStream` for as long as the screen is
/// visible and iterating it. Stop iterating (or cancel the owning `Task`)
/// when the screen disappears; don't leave a monitor running for the
/// app's whole lifetime just for a diagnostics view.
public actor NetworkReadiness {
private var monitor: NWPathMonitor?
public init() {}
/// Single point-in-time read: starts a path monitor, takes the first
/// update, and cancels. Cheap enough to call every time a diagnostics
/// screen appears don't cache this across app launches or poll it on
/// a timer (a real path change will call any *ongoing* monitor's
/// handler immediately; polling adds nothing but battery drain).
public func currentStatus() async -> NetworkStatus {
let monitor = NWPathMonitor()
return await withCheckedContinuation { continuation in
monitor.pathUpdateHandler = { path in
continuation.resume(returning: Self.buildStatus(from: path))
monitor.cancel()
}
monitor.start(queue: DispatchQueue(label: "indie-diag.network.oneshot"))
}
}
/// Ongoing updates for as long as the returned stream is iterated.
/// Only one monitor runs at a time per `NetworkReadiness` instance
/// starting a new stream cancels any previous one.
public func statusUpdates() -> AsyncStream<NetworkStatus> {
stop()
let monitor = NWPathMonitor()
self.monitor = monitor
return AsyncStream { continuation in
monitor.pathUpdateHandler = { path in
continuation.yield(Self.buildStatus(from: path))
}
continuation.onTermination = { _ in
monitor.cancel()
}
monitor.start(queue: DispatchQueue(label: "indie-diag.network.monitor"))
}
}
/// Cancels any monitor started by `statusUpdates()`. Call this when the
/// diagnostics screen disappears if you're not relying on the stream's
/// consumer dropping out to trigger `onTermination`.
public func stop() {
monitor?.cancel()
monitor = nil
}
private nonisolated static func buildStatus(from path: NWPath) -> NetworkStatus {
let isConnected = path.status == .satisfied
let connectionType: ConnectionType
if path.usesInterfaceType(.wifi) {
connectionType = .wifi
} else if path.usesInterfaceType(.cellular) {
connectionType = .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
connectionType = .ethernet
} else if isConnected {
connectionType = .unknown
} else {
connectionType = .none
}
let isExpensive = path.isExpensive
let isConstrained = path.isConstrained
// iCloud sync works best on Wi-Fi/Ethernet, or unconstrained cellular.
let supportsCloudSync = isConnected && (!isConstrained || connectionType == .wifi || connectionType == .ethernet)
return NetworkStatus(
isConnected: isConnected,
connectionType: connectionType,
isExpensive: isExpensive,
isConstrained: isConstrained,
supportsCloudSync: supportsCloudSync
)
}
}
@@ -0,0 +1,180 @@
//
// SchemaSkipScanner.swift
// Reference code copied from the indie-diag skill (2026-07-06 addition),
// adapted for Workouts.
//
// Answers a question `NSMetadataQuery` can't: of the documents that are fully
// synced and locally present, how many is this build currently *skipping*
// because their `schemaVersion` is newer than it understands (IndieSync's
// `VersionedDocument.isReadable` forward gate)? A skipped document is a
// healthy, expected state the data is safe and fully synced, just written by
// a newer app version on another device and updating the app recovers it.
//
// ADAPTATION FROM THE REFERENCE: the reference scanner takes a single
// `currentSchemaVersion: Int`. Workouts has TWO document types with
// INDEPENDENT version counters (`SplitDocument.currentSchemaVersion` and
// `WorkoutDocument.currentSchemaVersion`), so one global ceiling can't classify
// both correctly a Split written at a version the workout counter has already
// reached would be misjudged. The forward-gate ceiling therefore has to be
// resolved per file (its location under `Documents/` tells us its type), so the
// scanner takes a `CeilingResolver` closure. A single-Int convenience init is
// kept so the file stays drop-in for single-document-type apps. Deliberately
// never triggers a download: callers filter to locally-present documents first
// (see `DiagnosticsCoordinator.generate()`).
//
import Foundation
/// Outcome of probing one document's schema version.
public enum SchemaProbeState: Sendable, Equatable {
/// `schemaVersion` is at or below what this build understands.
case readable(version: Int)
/// `schemaVersion` is newer than this build understands the app's
/// `isReadable` forward gate skips this document. An app update
/// recovers it; the data is intact.
case skipped(version: Int)
/// Decoded as JSON but no schema version field was found (e.g. a
/// tombstone stub or other non-versioned file in the container).
case noVersionField
/// The file couldn't be read or parsed at all.
case unreadable(description: String)
}
/// Probe result for one document.
public struct SchemaProbeResult: Sendable, Identifiable {
public var id: URL { url }
public let url: URL
public let fileName: String
public let state: SchemaProbeState
}
/// Point-in-time result of scanning locally-available documents.
///
/// Only documents that were actually probed appear here evicted
/// documents are excluded upstream (never downloaded just to probe), so
/// `skippedCount` is a floor, not a guarantee, whenever
/// `DocumentSyncSnapshot.evictedCount` is nonzero.
public struct SchemaSkipSnapshot: Sendable {
public let results: [SchemaProbeResult]
public let currentSchemaVersion: Int
public let generatedAt: Date
public var probedCount: Int { results.count }
public var skippedCount: Int {
results.count { if case .skipped = $0.state { return true } else { return false } }
}
public var skipped: [SchemaProbeResult] {
results.filter { if case .skipped = $0.state { return true } else { return false } }
}
/// Highest schema version seen among skipped documents i.e. the
/// newest app version that has written into this container. Useful in
/// support copy ("another device is running a newer version").
public var newestSkippedVersion: Int? {
skipped.compactMap { if case .skipped(let v) = $0.state { return v } else { return nil } }.max()
}
}
/// Probes documents for their `schemaVersion` and counts the ones this
/// build would skip. A plain `actor` so the coordinated file reads block off
/// the caller's thread and a wedged iCloud account can't freeze the UI.
public actor SchemaSkipScanner {
/// Pulls the schema version out of a document's raw bytes. Return nil
/// if the data has no version field.
public typealias VersionExtractor = @Sendable (Data) -> Int?
/// Resolves the newest schema version THIS build can read for the document at
/// `url` its forward-gate ceiling. Workouts resolves this from the file's
/// location under `Documents/` (Splits/ vs Workouts/); see the file header.
public typealias CeilingResolver = @Sendable (URL) -> Int
private let ceilingForURL: CeilingResolver
private let extractVersion: VersionExtractor
/// Representative "newest schema this build understands" (the max ceiling),
/// kept only for `SchemaSkipSnapshot.currentSchemaVersion`'s informational use.
private let representativeCeiling: Int
/// Per-URL ceiling the form Workouts uses (two document types, two counters).
/// - Parameters:
/// - ceilingForURL: newest readable version for the document at a given URL.
/// - representativeCeiling: a single number for the snapshot's informational
/// field pass the max of the app's document-type ceilings.
/// - extractVersion: override when documents don't match the default layouts.
public init(
ceilingForURL: @escaping CeilingResolver,
representativeCeiling: Int,
extractVersion: VersionExtractor? = nil
) {
self.ceilingForURL = ceilingForURL
self.representativeCeiling = representativeCeiling
self.extractVersion = extractVersion ?? Self.defaultExtractor
}
/// Single global ceiling the reference package's original signature, kept so
/// this file stays drop-in for single-document-type apps.
public init(currentSchemaVersion: Int, extractVersion: VersionExtractor? = nil) {
self.init(
ceilingForURL: { _ in currentSchemaVersion },
representativeCeiling: currentSchemaVersion,
extractVersion: extractVersion
)
}
/// Probe the given document URLs. Pass only locally-present documents
/// (filter a `DocumentSyncSnapshot` down to states that imply a full
/// local copy) this scanner reads whatever it's handed, and reading
/// an evicted document would trigger a full download.
public func scan(urls: [URL]) -> SchemaSkipSnapshot {
SchemaSkipSnapshot(
results: urls.map(probe),
currentSchemaVersion: representativeCeiling,
generatedAt: Date()
)
}
private func probe(_ url: URL) -> SchemaProbeResult {
let fileName = url.lastPathComponent
// Coordinated read, same as the sync engine's document reads a
// plain Data(contentsOf:) can race an in-flight iCloud update.
var coordinationError: NSError?
var data: Data?
var readErrorDescription: String?
NSFileCoordinator(filePresenter: nil).coordinate(
readingItemAt: url, options: .withoutChanges, error: &coordinationError
) { actualURL in
do { data = try Data(contentsOf: actualURL) }
catch { readErrorDescription = error.localizedDescription }
}
guard let data else {
let description = coordinationError?.localizedDescription
?? readErrorDescription
?? "Unknown read error"
return SchemaProbeResult(url: url, fileName: fileName, state: .unreadable(description: description))
}
guard let version = extractVersion(data) else {
return SchemaProbeResult(url: url, fileName: fileName, state: .noVersionField)
}
let ceiling = ceilingForURL(url)
let state: SchemaProbeState = version > ceiling
? .skipped(version: version)
: .readable(version: version)
return SchemaProbeResult(url: url, fileName: fileName, state: state)
}
/// Covers both canonical portfolio layouts: top-level `schemaVersion`
/// (IndieSync's `VersionedDocument`) and `meta.schemaVersion`
/// (time-series-data-model's Meta+Payload). Decodes only the version
/// field never the full document, so a future schema can't trip it.
private static let defaultExtractor: VersionExtractor = { data in
struct TopLevel: Decodable { let schemaVersion: Int }
struct MetaWrapped: Decodable {
struct Meta: Decodable { let schemaVersion: Int }
let meta: Meta
}
let decoder = JSONDecoder()
if let doc = try? decoder.decode(TopLevel.self, from: data) { return doc.schemaVersion }
if let doc = try? decoder.decode(MetaWrapped.self, from: data) { return doc.meta.schemaVersion }
return nil
}
}
@@ -129,6 +129,12 @@ struct SettingsView: View {
Label(error, systemImage: "exclamationmark.icloud.fill")
.foregroundStyle(.orange)
}
NavigationLink {
DiagnosticsView()
} label: {
Label("Diagnostics", systemImage: "stethoscope")
}
} header: {
Text("iCloud Sync")
}