diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 511fd89..f19aed2 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -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() { diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index f2f2f13..002a622 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -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