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,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
)
}
}