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
247 lines
10 KiB
Swift
247 lines
10 KiB
Swift
//
|
|
// 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
|
|
)
|
|
}
|
|
}
|