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