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:
Robin Kutesa
2026-05-18 16:56:16 +03:00
parent d2190bce8a
commit ae22d7a739
2 changed files with 57 additions and 32 deletions

View File

@@ -646,9 +646,11 @@ struct BackupView: View {
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel)
} }
if lanMonitor.nasReachable == false { if lanMonitor.nasReachable == false {
Text("Unreachable") Text(statusService.refreshError ?? "Unreachable")
.font(.system(size: 9, weight: .regular)) .font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.destructive.opacity(0.7)) .foregroundStyle(AppTheme.destructive.opacity(0.7))
.lineLimit(2)
.multilineTextAlignment(.trailing)
} else if lanMonitor.nasReachable == true && !engine.job.status.isActive { } else if lanMonitor.nasReachable == true && !engine.job.status.isActive {
Text("Connected") Text("Connected")
.font(.system(size: 9, weight: .regular)) .font(.system(size: 9, weight: .regular))
@@ -691,43 +693,56 @@ struct BackupView: View {
private var actionButton: some View { private var actionButton: some View {
Group { Group {
switch resolvedStatus { // Coordinator is running a status check block the start button so
case .idle, .completed, .failed, .cancelled: // a backup cannot be triggered before reconciliation completes.
Button(action: startBackup) { if coordinator.phase == .checking {
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
}
.buttonStyle(PrimaryButtonStyle(
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
))
.accessibilityHint("Starts backing up your photos to the NAS")
case .running:
Button { engine.pause() } label: { Text("Pause") }
.buttonStyle(GhostButtonStyle())
.accessibilityLabel("Pause backup")
case .paused:
HStack(spacing: 10) {
Button { engine.resume() } label: { Text("Resume") }
.buttonStyle(PrimaryButtonStyle())
.accessibilityLabel("Resume backup")
Button { engine.cancel() } label: { Text("Cancel") }
.buttonStyle(GhostButtonStyle())
.frame(width: 90)
.accessibilityLabel("Cancel backup")
}
case .preparing:
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary) ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
Text("Preparing") Text("Checking status")
.font(AppTheme.body()) .font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkSecondary)
} }
.frame(height: 50) .frame(height: 50)
} else {
switch resolvedStatus {
case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) {
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
}
.buttonStyle(PrimaryButtonStyle(
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
))
.accessibilityHint("Starts backing up your photos to the NAS")
case .running:
Button { engine.pause() } label: { Text("Pause") }
.buttonStyle(GhostButtonStyle())
.accessibilityLabel("Pause backup")
case .paused:
HStack(spacing: 10) {
Button { engine.resume() } label: { Text("Resume") }
.buttonStyle(PrimaryButtonStyle())
.accessibilityLabel("Resume backup")
Button { engine.cancel() } label: { Text("Cancel") }
.buttonStyle(GhostButtonStyle())
.frame(width: 90)
.accessibilityLabel("Cancel backup")
}
case .preparing:
HStack(spacing: 8) {
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
Text("Preparing…")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
}
.frame(height: 50)
}
} }
} }
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running) .animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: coordinator.phase == .checking)
} }
private func startBackup() { private func startBackup() {

View File

@@ -240,6 +240,12 @@ final class BackupStatusService: NSObject, ObservableObject {
updated.lastCheckedAt = Date() updated.lastCheckedAt = Date()
snapshot = updated snapshot = updated
saveSnapshot() 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. /// Builds a ManifestIndex from NAS.
@@ -250,8 +256,10 @@ final class BackupStatusService: NSObject, ObservableObject {
path: String, path: String,
conn: NASConnection conn: NASConnection
) async -> ManifestIndex { ) async -> ManifestIndex {
log.info("buildManifestIndex: remotePath=\(conn.remotePath, privacy: .public) manifestFile=\(path, privacy: .public)")
do { do {
let data = try await transfer.downloadData(at: path) 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. // Decode and build index off main actor JSONDecoder is not cheap for large manifests.
let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) { let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) {
guard let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) 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) return (ManifestIndex(manifest: manifest), manifest)
}.value }.value
if let (index, manifest) = decoded { if let (index, manifest) = decoded {
log.info("buildManifestIndex: decoded \(index.totalCount) entries (isFilenameOnly=\(index.isFilenameOnly))")
lastManifest = manifest lastManifest = manifest
Task { await NASManifestCache.shared.update(manifest) } Task { await NASManifestCache.shared.update(manifest) }
@@ -270,20 +279,21 @@ final class BackupStatusService: NSObject, ObservableObject {
let dirCount = items.filter { let dirCount = items.filter {
!$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename !$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename
}.count }.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 { if dirCount > index.totalCount {
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount) return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
} }
} }
return index return index
} }
log.warning("buildManifestIndex: manifest downloaded but failed to decode (\(data.count) bytes)")
} catch { } 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. // Bootstrap: list NAS directory and build filename-only index in background.
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? [] 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) { return await Task.detached(priority: .utility) {
ManifestIndex(nasListing: items) ManifestIndex(nasListing: items)
}.value }.value