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 private static let staleInterval: TimeInterval = 5 * 60 private struct CachedEntry: Codable { var manifest: BackupManifest var savedAt: Date } private init() { let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] diskURL = caches.appendingPathComponent("kisani_manifest_cache.json") // Load from disk immediately so the first `manifest` access is instant. 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") } } /// Returns the cached manifest without any I/O. Never nil after first reconcile. var manifest: BackupManifest? { cached?.manifest } /// 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. Call after every successful NAS download. func update(_ manifest: BackupManifest) { let entry = CachedEntry(manifest: manifest, savedAt: Date()) cached = entry log.info("ManifestCache: updated — \(manifest.entries.count) entries") 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 }() }