Finish backup spec: button state, invariant assertion, NAS error surfacing
- BackupView: action button shows "Checking status…" with spinner and blocks taps while coordinator.phase == .checking — prevents starting a backup before reconciliation has determined what actually needs uploading - BackupView: NAS status row shows statusService.refreshError (actual SMB error reason: auth failure, timeout, host unreachable) instead of the generic "Unreachable" string — surfaces the real diagnosis to the user - BackupStatusService: invariant assertion after every full reconcile — logs .error if alreadySafe + needBackup ≠ phoneTotal so filter mismatches and off-by-ones surface immediately in Console.app - BackupStatusService.buildManifestIndex: full diagnostic logging — remotePath, manifest file path, download byte count, decoded entry count, bootstrap fallback reason — all visible in Console.app filtered by "BackupStatus" Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -240,6 +240,12 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
updated.lastCheckedAt = Date()
|
||||
snapshot = updated
|
||||
saveSnapshot()
|
||||
|
||||
// Invariant check — alreadySafe + needBackup must always equal phoneTotal
|
||||
let inv = updated.alreadySafe + updated.needBackup
|
||||
if inv != updated.phoneTotal {
|
||||
log.error("Invariant broken: alreadySafe(\(updated.alreadySafe)) + needBackup(\(updated.needBackup)) = \(inv) ≠ phoneTotal(\(updated.phoneTotal))")
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a ManifestIndex from NAS.
|
||||
@@ -250,8 +256,10 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
path: String,
|
||||
conn: NASConnection
|
||||
) async -> ManifestIndex {
|
||||
log.info("buildManifestIndex: remotePath=\(conn.remotePath, privacy: .public) manifestFile=\(path, privacy: .public)")
|
||||
do {
|
||||
let data = try await transfer.downloadData(at: path)
|
||||
log.info("buildManifestIndex: manifest downloaded — \(data.count) bytes")
|
||||
// Decode and build index off main actor — JSONDecoder is not cheap for large manifests.
|
||||
let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) {
|
||||
guard let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
|
||||
@@ -259,6 +267,7 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
return (ManifestIndex(manifest: manifest), manifest)
|
||||
}.value
|
||||
if let (index, manifest) = decoded {
|
||||
log.info("buildManifestIndex: decoded \(index.totalCount) entries (isFilenameOnly=\(index.isFilenameOnly))")
|
||||
lastManifest = manifest
|
||||
Task { await NASManifestCache.shared.update(manifest) }
|
||||
|
||||
@@ -270,20 +279,21 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
let dirCount = items.filter {
|
||||
!$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename
|
||||
}.count
|
||||
log.info("buildManifestIndex: manifest=\(index.totalCount) dir=\(dirCount) — using max for nasArchiveTotal")
|
||||
log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max")
|
||||
if dirCount > index.totalCount {
|
||||
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
log.warning("buildManifestIndex: manifest downloaded but failed to decode (\(data.count) bytes)")
|
||||
} catch {
|
||||
// File not found — fall through to directory listing bootstrap
|
||||
log.info("buildManifestIndex: manifest not found at \(path, privacy: .public) — \(error.localizedDescription, privacy: .public) — falling back to directory listing")
|
||||
}
|
||||
|
||||
// Bootstrap: list NAS directory and build filename-only index in background.
|
||||
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
|
||||
log.info("Manifest not found — bootstrapping from \(items.count) NAS items")
|
||||
log.info("buildManifestIndex: bootstrap from \(items.count) items in \(conn.remotePath, privacy: .public)")
|
||||
return await Task.detached(priority: .utility) {
|
||||
ManifestIndex(nasListing: items)
|
||||
}.value
|
||||
|
||||
Reference in New Issue
Block a user