import Foundation /// Wraps a single `NSMetadataQuery` over the container's `Documents/` scope and /// emits add/modify/remove events (paths relative to `Documents/`) via an /// `AsyncStream`. `@MainActor` because `NSMetadataQuery` posts on, and must be /// driven from, the main thread. @MainActor final class ICloudFileMonitor { enum FileChangeEvent: Sendable { case added(relativePath: String) case modified(relativePath: String) case removed(relativePath: String) } private let documentsURL: URL private var query: NSMetadataQuery? private var knownFiles: Set = [] private var continuation: AsyncStream.Continuation? init(documentsURL: URL) { self.documentsURL = documentsURL } func events() -> AsyncStream { AsyncStream { continuation in self.continuation = continuation continuation.onTermination = { @Sendable _ in Task { @MainActor in self.stop() } } } } func start() { let query = NSMetadataQuery() query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] query.predicate = NSPredicate(format: "%K LIKE '*.json'", NSMetadataItemFSNameKey) self.query = query NotificationCenter.default.addObserver( self, selector: #selector(queryDidFinishGathering(_:)), name: .NSMetadataQueryDidFinishGathering, object: query) NotificationCenter.default.addObserver( self, selector: #selector(queryDidUpdate(_:)), name: .NSMetadataQueryDidUpdate, object: query) query.start() } func stop() { query?.stop() if let query { NotificationCenter.default.removeObserver(self, name: nil, object: query) } query = nil } // MARK: - Notifications @objc private func queryDidFinishGathering(_ notification: Notification) { query?.disableUpdates() defer { query?.enableUpdates() } // Seed the baseline only; do NOT emit (else every existing file would fire // `.added` on each launch). The engine's connect-time `reconcile()` does the // initial import; this query then reports only live deltas after the baseline. knownFiles = Set(currentRelativePaths()) } @objc private func queryDidUpdate(_ notification: Notification) { query?.disableUpdates() defer { query?.enableUpdates() } let currentFiles = Set(currentRelativePaths()) for file in currentFiles.subtracting(knownFiles) { continuation?.yield(.added(relativePath: file)) } for file in knownFiles.subtracting(currentFiles) { continuation?.yield(.removed(relativePath: file)) } if let updated = notification.userInfo?[NSMetadataQueryUpdateChangedItemsKey] as? [NSMetadataItem] { for item in updated { if let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL, let path = relativePath(from: url), knownFiles.contains(path) { continuation?.yield(.modified(relativePath: path)) } } } knownFiles = currentFiles } // MARK: - Helpers private func currentRelativePaths() -> [String] { guard let query else { return [] } var paths: [String] = [] for i in 0.. String? { let base = documentsURL.path + "/" let full = url.path guard full.hasPrefix(base) else { return nil } let relative = String(full.dropFirst(base.count)) return relative.hasSuffix(".json") ? relative : nil } }