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)
}
if lanMonitor.nasReachable == false {
Text("Unreachable")
Text(statusService.refreshError ?? "Unreachable")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.destructive.opacity(0.7))
.lineLimit(2)
.multilineTextAlignment(.trailing)
} else if lanMonitor.nasReachable == true && !engine.job.status.isActive {
Text("Connected")
.font(.system(size: 9, weight: .regular))
@@ -691,43 +693,56 @@ struct BackupView: View {
private var actionButton: some View {
Group {
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:
// Coordinator is running a status check block the start button so
// a backup cannot be triggered before reconciliation completes.
if coordinator.phase == .checking {
HStack(spacing: 8) {
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
Text("Preparing")
Text("Checking status")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
}
.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: coordinator.phase == .checking)
}
private func startBackup() {