73 lines
2.6 KiB
Swift
73 lines
2.6 KiB
Swift
|
|
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
|
||
|
|
}()
|
||
|
|
}
|