Add live backup status reconciliation with NAS manifest

BackupStatusService (new singleton, ObservableObject, PHPhotoLibraryChangeObserver):
- Loads cached BackupStatusSnapshot instantly from UserDefaults on init
- Full reconcile: fetch phone assets → connect NAS → load/build manifest → compare
  → enforce invariant (alreadySafe ≤ phoneTotal), persist result
- Debounced (30s) for photo library changes; force=true bypasses debounce
- If NAS unreachable: keeps last cached numbers, marks connectionState = .offline
- PHPhotoLibraryChangeObserver triggers refresh on any library change

BackupManifest (new):
- Stored at {remotePath}/.kisani.json on NAS
- Indexed by localIdentifier (PHAsset stable ID); filename fallback for legacy entries
- Built from directory listing if manifest missing (bootstrap for existing backups)
- merged/updated after each backup run via NASTransferProtocol.writeData

BackupStatusSnapshot (new):
- Single source of truth: phoneTotal, alreadySafe, needBackup (derived), nasArchiveTotal,
  lastCheckedAt, connectionState
- Invariant enforced in service: alreadySafe = min(safe, phoneTotal)

Protocol / services:
- NASTransferProtocol: adds writeData(_ data: Data, to remotePath: String)
- SMBService: implements writeData via temp file + SMBClient.upload
- SFTPService: implements writeData via SFTP ByteBuffer write

BackupEngine:
- Tracks ManifestEntry per successful upload during backup loop
- After backup: calls BackupStatusService.refreshAfterBackup(entries:connection:)
  which applies optimistic UI update then writes manifest + triggers reconcile

BackupView:
- Reads all stats from BackupStatusService.snapshot (not vm/nasFileCount)
- Stats labels: "NAS Archive" / "On iPhone" / "Need Backup" / "Already Safe"
- Live refresh triggers: .task (force), scenePhase.active (force),
  nasReachable change (force), remotePath change (force), backup completed (+2s)
- Subtle status row below stats: "Checking…" spinner or "Updated X ago" with
  wifi-slash icon when NAS offline; tap refresh button for forced reconcile
- AppMenuView sheet now correctly passes engine EnvironmentObject

BackupViewModel: stripped to auth-only (photosAuthStatus + requestPhotosAccess)

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 12:51:03 +03:00
parent 1312ebb278
commit 712e110f57
10 changed files with 412 additions and 70 deletions

View File

@@ -0,0 +1,221 @@
import Foundation
import Photos
@MainActor
final class BackupStatusService: NSObject, ObservableObject {
static let shared = BackupStatusService()
@Published private(set) var snapshot: BackupStatusSnapshot = .empty
@Published private(set) var isRefreshing: Bool = false
@Published private(set) var refreshError: String?
private let photoService = PhotoLibraryService()
private let cacheKey = "backupStatusSnapshot_v2"
private var refreshTask: Task<Void, Never>?
private var lastFullRefresh: Date?
private override init() {
super.init()
loadCachedSnapshot()
PHPhotoLibrary.shared().register(self)
}
// MARK: Public
/// Trigger a full reconciliation. `force: true` bypasses the 30-second debounce.
func refresh(force: Bool = false) {
refreshTask?.cancel()
refreshTask = Task { [weak self] in
await self?.performRefresh(force: force)
}
}
/// Called by BackupEngine after a completed run.
/// Applies an optimistic update immediately, then writes the manifest and reconciles in background.
func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {
let uploaded = entries.count
snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded)
snapshot.nasArchiveTotal += uploaded
saveSnapshot()
Task {
await writeManifest(entries: entries, connection: connection)
refresh(force: true)
}
}
// MARK: Core refresh
private func performRefresh(force: Bool) async {
guard !isRefreshing else { return }
// Debounce: non-forced refreshes skip the NAS round-trip if we just reconciled
if !force, let last = lastFullRefresh, Date().timeIntervalSince(last) < 30 {
updatePhoneCountOnly()
return
}
lastFullRefresh = Date()
isRefreshing = true
refreshError = nil
do {
try await reconcile()
} catch is CancellationError {
// Swallow a newer refresh superseded this one
} catch {
refreshError = error.localizedDescription
// Keep last cached numbers; mark NAS offline
snapshot.connectionState = .offline
snapshot.lastCheckedAt = Date()
saveSnapshot()
}
isRefreshing = false
}
private func reconcile() async throws {
let store = ConnectionStore.shared
guard let conn = store.savedConnection else { return }
// 1. Count phone assets (fast, no network)
let filter = store.backupFilter
let assets = photoService.fetchAssets(filter: filter)
let phoneTotal = assets.count
try Task.checkCancellation()
// 2. Connect to NAS
let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
do {
try await transfer.connect(
to: conn.host, port: conn.port,
username: conn.username, password: conn.password
)
} catch {
// NAS offline update phone count only, enforce invariant on cached safe count
snapshot.phoneTotal = phoneTotal
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
snapshot.connectionState = .offline
snapshot.lastCheckedAt = Date()
saveSnapshot()
return
}
defer { transfer.disconnect() }
try Task.checkCancellation()
// 3. Load or bootstrap the manifest
let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)"
let manifest: BackupManifest
do {
let data = try await transfer.downloadData(at: manifestPath)
manifest = (try? JSONDecoder().decode(BackupManifest.self, from: data)) ?? {
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
return BackupManifest.buildFromNASListing(items)
}()
} catch {
// Manifest not found build from directory listing
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
manifest = BackupManifest.buildFromNASListing(items)
}
try Task.checkCancellation()
// 4. Compare localIdentifier first, filename fallback for legacy entries
var safe = 0
for asset in assets {
if manifest.contains(localIdentifier: asset.localIdentifier)
|| manifest.containsByFilename(asset.filename) {
safe += 1
}
}
// 5. NAS archive count (excludes hidden manifest file)
let nasArchive = manifest.entries.filter { !$0.filename.hasPrefix(".") }.count
// 6. Commit enforce invariant
var updated = BackupStatusSnapshot()
updated.phoneTotal = phoneTotal
updated.alreadySafe = min(safe, phoneTotal) // invariant: never > phoneTotal
updated.nasArchiveTotal = nasArchive
updated.connectionState = .connected
updated.lastCheckedAt = Date()
snapshot = updated
saveSnapshot()
}
// Fast phone-only update used when debouncing NAS round-trips
private func updatePhoneCountOnly() {
let filter = ConnectionStore.shared.backupFilter
let assets = photoService.fetchAssets(filter: filter)
let phoneTotal = assets.count
if phoneTotal != snapshot.phoneTotal {
snapshot.phoneTotal = phoneTotal
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
saveSnapshot()
}
}
// MARK: Manifest write (non-blocking background operation)
func writeManifest(entries: [ManifestEntry], connection: NASConnection) async {
guard !entries.isEmpty else { return }
let transfer: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService()
do {
try await transfer.connect(
to: connection.host, port: connection.port,
username: connection.username, password: connection.password
)
defer { transfer.disconnect() }
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
var manifest: BackupManifest
do {
let data = try await transfer.downloadData(at: manifestPath)
manifest = (try? JSONDecoder().decode(BackupManifest.self, from: data)) ?? BackupManifest()
} catch {
manifest = BackupManifest()
}
manifest.merge(entries: entries)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(manifest)
try await transfer.writeData(data, to: manifestPath)
} catch {
// Manifest write failure is non-fatal reconciliation rebuilds it next time
}
}
// MARK: Cache
private func loadCachedSnapshot() {
guard let data = UserDefaults.standard.data(forKey: cacheKey),
let cached = try? JSONDecoder().decode(BackupStatusSnapshot.self, from: data)
else { return }
snapshot = cached
}
private func saveSnapshot() {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(snapshot) {
UserDefaults.standard.set(data, forKey: cacheKey)
}
}
}
// MARK: PHPhotoLibraryChangeObserver
extension BackupStatusService: PHPhotoLibraryChangeObserver {
nonisolated func photoLibraryDidChange(_ changeInstance: PHChange) {
Task { @MainActor [weak self] in
self?.refresh() // debounced won't hit NAS more than once per 30s
}
}
}