import Foundation import os.log private let log = Logger(subsystem: "com.albert.nasbackup", category: "ManifestCache") /// Persists a copy of the NAS backup manifest on-device so reconciliation can /// skip the NAS network round-trip when the cache is still fresh. /// /// Cache is updated after every successful manifest download and after every /// completed backup. Stale threshold = 5 minutes. The actor serialises all /// reads and writes so the cache is safe to use across tasks and threads. actor NASManifestCache { static let shared = NASManifestCache() private var cached: CachedEntry? private let diskURL: URL // 30-minute window: directoryCount is updated incrementally after every upload, // and PHPhotoLibraryChangeObserver keeps LocalPhotoIndex current. A full NAS // roundtrip every 5 minutes was unnecessary and caused "Checking…" stalls. private static let staleInterval: TimeInterval = 30 * 60 private struct CachedEntry: Codable { var manifest: BackupManifest var savedAt: Date /// Real NAS destination folder file count from the last listDirectory scan. /// Stored independently from manifest.entries.count — the folder may contain /// files uploaded by other tools that are not in Kisani's manifest. var directoryCount: Int? /// Filtered NAS filenames from the last validity-check or bootstrap scan. /// Present only when the manifest was found sparse vs. actual NAS contents. /// Used by the fast-path reconcile to do filename matching without hitting NAS. var directoryFilenames: [String]? } private init() { let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] diskURL = caches.appendingPathComponent("kisani_manifest_cache.json") if let data = try? Data(contentsOf: diskURL), let entry = try? JSONDecoder.kisani.decode(CachedEntry.self, from: data) { cached = entry log.info("ManifestCache: loaded \(entry.manifest.entries.count) entries from disk, dirCount=\(entry.directoryCount.map(String.init) ?? "nil")") } } /// Returns the cached manifest without any I/O. Never nil after first reconcile. var manifest: BackupManifest? { cached?.manifest } /// Real NAS folder file count from the last listDirectory scan. /// nil until the first full reconcile completes. var directoryCount: Int? { cached?.directoryCount } /// Cached NAS filenames from the last validity-check scan. /// nil until the first validity-check reconcile completes with a sparse manifest. var directoryFilenames: [String]? { cached?.directoryFilenames } /// True when the cache is absent or older than the stale threshold. var isStale: Bool { guard let c = cached else { return true } return Date().timeIntervalSince(c.savedAt) > NASManifestCache.staleInterval } /// Persist a freshly-downloaded manifest. Preserves any existing directoryCount. func update(_ manifest: BackupManifest) { cached = CachedEntry(manifest: manifest, savedAt: Date(), directoryCount: cached?.directoryCount) log.info("ManifestCache: updated — \(manifest.entries.count) entries") saveToDisk() } /// Store the real NAS destination folder file count (from listDirectory scan). func setDirectoryCount(_ count: Int) { if var entry = cached { entry.directoryCount = count cached = entry } else { cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, directoryCount: count) } log.info("ManifestCache: directoryCount=\(count)") saveToDisk() } /// Store filtered NAS filenames from a validity-check or bootstrap scan. /// Also updates directoryCount atomically from the filename array length. func setDirectoryFilenames(_ filenames: [String]) { let count = filenames.count if var entry = cached { entry.directoryCount = count entry.directoryFilenames = filenames cached = entry } else { cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, directoryCount: count, directoryFilenames: filenames) } log.info("ManifestCache: directoryFilenames=\(count)") saveToDisk() } /// Increment the stored directory count after a successful upload batch. func addToDirectoryCount(_ delta: Int) { guard delta > 0 else { return } let next = (cached?.directoryCount ?? 0) + delta if var entry = cached { entry.directoryCount = next cached = entry } else { cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, directoryCount: next) } log.info("ManifestCache: directoryCount += \(delta) → \(next)") saveToDisk() } private func saveToDisk() { guard let entry = cached else { return } Task.detached(priority: .utility) { [entry, url = diskURL] in guard let data = try? JSONEncoder.kisani.encode(entry) else { return } try? data.write(to: url, options: .atomic) } } } // MARK: — Shared encoder/decoder extension JSONEncoder { static let kisani: JSONEncoder = { let e = JSONEncoder() e.dateEncodingStrategy = .iso8601 return e }() } extension JSONDecoder { static let kisani: JSONDecoder = { let d = JSONDecoder() d.dateDecodingStrategy = .iso8601 return d }() }