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