Fix backup loop, NAS Archive regression, and manifest validity check

- AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task
  and scenePhase triggers (navigation appear, rapid foreground cycles) are
  no-ops; start a 60s periodic recheck timer on first active call; stop timer
  and reset debounce on willResignActiveNotification so each new foreground
  session always gets an immediate fresh status check

- BackupView: remove the redundant lanMonitor.nasReachable onChange that was
  calling statusService.refresh() directly — coordinator already handles this
  via its Combine $nasReachable subscriber, avoiding a double reconcile that
  could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value

- BackupStatusService: when NAS connect fails, restore nasArchiveTotal from
  NASManifestCache before writing the offline snapshot — prevents the count
  from showing 0 when the NAS is temporarily unreachable but the cache is warm

- BackupStatusService: manifest validity check — if the decoded manifest has
  ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the
  real file count as the display floor for nasArchiveTotal without adding
  orphan entries to the manifest (that was the 7421 regression)

- BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for
  the validity check path — keeps localIdentifier matching intact while
  correcting the displayed archive count

- SMBService: add os.log around connect/auth/listDirectory with actual error
  reason instead of always surfacing authenticationFailed; distinguish network
  errors (timeout, host unreachable) from auth errors in thrown BackupError

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 16:07:41 +03:00
parent 3beea88fab
commit d2190bce8a
5 changed files with 114 additions and 11 deletions

View File

@@ -182,12 +182,22 @@ final class BackupStatusService: NSObject, ObservableObject {
username: conn.username, password: conn.password
)
} catch {
let reason = (error as? BackupError).flatMap { $0.errorDescription } ?? error.localizedDescription
log.warning("NAS connect failed: \(reason, privacy: .public) — retaining cached counts")
refreshError = reason
// Restore last-known NAS archive count from manifest cache so the dashboard
// doesn't show 0 when the NAS is temporarily offline.
if let cached = await NASManifestCache.shared.manifest {
let cachedCount = ManifestIndex(manifest: cached).totalCount
snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount)
log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) from cache")
}
snapshot.phoneTotal = phoneTotal
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
snapshot.connectionState = .offline
snapshot.lastCheckedAt = Date()
saveSnapshot()
log.warning("NAS offline — cached numbers retained")
return
}
defer { transfer.disconnect() }
@@ -250,8 +260,21 @@ final class BackupStatusService: NSObject, ObservableObject {
}.value
if let (index, manifest) = decoded {
lastManifest = manifest
// Persist to local cache so future launches skip the NAS round-trip.
Task { await NASManifestCache.shared.update(manifest) }
// Validity check: if the manifest has 1 entries it may be corrupt or newly
// created. Scan the directory to get the real file count for the NAS Archive
// display but do NOT add directory files as orphan manifest entries.
if index.totalCount <= 1 {
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
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")
if dirCount > index.totalCount {
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
}
}
return index
}
} catch {