Fix backup progress state machine: pending queue, resolvedStatus, failure resolution

- BackupEngine: pre-filter assets against ManifestIndex before job.start() so
  totalFiles reflects only pending items (not full gallery). Short-circuit with
  allAlreadySafe() when queue is empty.
- BackupJob: add resolveSuccess() (upgrades stale .failed → .completed after
  reconciliation proves needBackup==0) and allAlreadySafe() for empty-queue case.
- BackupStatusService: expose refreshAndWait(force:) async for pull-to-refresh
  and post-failure reconciliation.
- BackupView: wire resolvedStatus throughout ringContentID, ringMainView,
  ringColor, and actionButton; fix progress label to show pending-queue
  denominator; handle .failed in onChange by reconciling and auto-resolving
  to .completed when counts confirm all photos are safe.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 15:58:54 +03:00
parent 819f2b949e
commit f40cf9ee37
5 changed files with 146 additions and 99 deletions

View File

@@ -77,6 +77,18 @@ final class BackupJob: ObservableObject {
: .failed("Completed with \(result.failedCount) error\(result.failedCount == 1 ? "" : "s")") : .failed("Completed with \(result.failedCount) error\(result.failedCount == 1 ? "" : "s")")
} }
/// Call after reconciliation proves needBackup == 0 upgrades stale .failed to .completed.
func resolveSuccess() {
if case .failed = status { status = .completed }
}
/// Used when the pending queue is empty at backup start nothing to do.
func allAlreadySafe() {
totalFiles = 0; uploadedFiles = 0; skippedFiles = 0; failedFiles = 0
startDate = Date(); endDate = Date()
status = .completed
}
func fail(error: BackupError) { func fail(error: BackupError) {
endDate = Date() endDate = Date()
status = .failed(error.errorDescription ?? error.localizedDescription) status = .failed(error.errorDescription ?? error.localizedDescription)

View File

@@ -17,11 +17,89 @@ struct BackupView: View {
private let ringPageCount = 4 private let ringPageCount = 4
// Counts are the source of truth for final display state.
// If job ended in .failed but reconciliation shows needBackup == 0, treat as .completed.
private var resolvedStatus: BackupStatus {
if case .failed = engine.job.status,
!statusService.isRefreshing,
statusService.snapshot.phoneTotal > 0,
statusService.snapshot.needBackup == 0 {
return .completed
}
return engine.job.status
}
var body: some View { var body: some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
AppTheme.background.ignoresSafeArea() AppTheme.background.ignoresSafeArea()
// Menu aligned with kisani. wordmark // Scrollable content with pull-to-refresh
GeometryReader { geo in
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
// Brand
VStack(spacing: 10) {
Text("kisani.")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(AppTheme.ink)
.kerning(2)
KisaniLogoMark(size: 66)
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
Spacer(minLength: 24).frame(maxHeight: 48)
// Ring
ringHero
.padding(.bottom, 44)
// Stats
statsStrip
.padding(.horizontal, AppTheme.hPad)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
Spacer(minLength: 20).frame(maxHeight: 48)
// Storage card
nasStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
// Destination card
destinationRow
.padding(.horizontal, AppTheme.hPad)
Spacer(minLength: 20).frame(maxHeight: 32)
// Photos access nudge
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
photosAccessRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 8)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// CTA
actionButton
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
Spacer(minLength: 0)
}
.frame(minHeight: geo.size.height)
}
.refreshable {
await statusService.refreshAndWait(force: true)
// After reconciliation, clear stale failure if counts say all safe
if statusService.snapshot.needBackup == 0 {
engine.resolveSuccess()
}
}
}
// Menu button floats above scroll content
Button { showMenu = true } label: { Button { showMenu = true } label: {
Image(systemName: "line.3.horizontal") Image(systemName: "line.3.horizontal")
.font(.system(size: 14, weight: .regular)) .font(.system(size: 14, weight: .regular))
@@ -32,63 +110,6 @@ struct BackupView: View {
.accessibilityLabel("Activity menu") .accessibilityLabel("Activity menu")
.padding(.top, 4) .padding(.top, 4)
.padding(.trailing, AppTheme.hPad - 4) .padding(.trailing, AppTheme.hPad - 4)
VStack(spacing: 0) {
// Brand
VStack(spacing: 10) {
Text("kisani.")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(AppTheme.ink)
.kerning(2)
KisaniLogoMark(size: 66)
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
// flex zone brand ring (absorbs screen-size variance)
Spacer(minLength: 24).frame(maxHeight: 48)
// Ring
ringHero
.padding(.bottom, 44)
// Stats
statsStrip
.padding(.horizontal, AppTheme.hPad)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
// flex zone stats cards
Spacer(minLength: 20).frame(maxHeight: 48)
// Storage card
nasStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
// Source card
destinationRow
.padding(.horizontal, AppTheme.hPad)
// flex zone cards CTA
Spacer(minLength: 20).frame(maxHeight: 32)
// Photos access nudge
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
photosAccessRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 8)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// CTA
actionButton
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
// bottom absorber soaks up remaining height to anchor cards+CTA
Spacer(minLength: 0)
}
} }
.navigationTitle("") .navigationTitle("")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -124,15 +145,24 @@ struct BackupView: View {
} }
.onChange(of: engine.job.status) { status in .onChange(of: engine.job.status) { status in
if status == .completed { if status == .completed {
// Flash green then revert to black
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true } withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
Task { Task {
try? await Task.sleep(nanoseconds: 2_500_000_000) try? await Task.sleep(nanoseconds: 2_500_000_000)
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false } withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
// Full reconcile after flash to get accurate counts
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(nanoseconds: 500_000_000)
statusService.refresh(force: true) statusService.refresh(force: true)
} }
} else if case .failed = status {
// Reconcile if all files landed on NAS despite the failure, auto-resolve to .completed
Task {
await statusService.refreshAndWait(force: true)
if statusService.snapshot.needBackup == 0 {
engine.resolveSuccess()
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
try? await Task.sleep(nanoseconds: 2_500_000_000)
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
}
}
} }
} }
} }
@@ -181,7 +211,7 @@ struct BackupView: View {
// ID that changes on both page swipe and status transition drives the ring animation // ID that changes on both page swipe and status transition drives the ring animation
private var ringContentID: String { private var ringContentID: String {
switch engine.job.status { switch resolvedStatus {
case .idle, .cancelled: return "\(ringPage)_idle" case .idle, .cancelled: return "\(ringPage)_idle"
case .preparing: return "\(ringPage)_preparing" case .preparing: return "\(ringPage)_preparing"
case .running: return "\(ringPage)_running" case .running: return "\(ringPage)_running"
@@ -238,7 +268,7 @@ struct BackupView: View {
// Page 0 content changes based on backup state // Page 0 content changes based on backup state
@ViewBuilder @ViewBuilder
private var ringMainView: some View { private var ringMainView: some View {
switch engine.job.status { switch resolvedStatus {
case .idle, .cancelled: case .idle, .cancelled:
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
VStack(spacing: 8) { VStack(spacing: 8) {
@@ -279,7 +309,7 @@ struct BackupView: View {
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit() .monospacedDigit()
Text("\(engine.job.uploadedFiles) of \(engine.job.totalFiles) backed up") Text("Backing up \(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkSecondary)
if let eta = engine.job.eta { if let eta = engine.job.eta {
@@ -326,15 +356,11 @@ struct BackupView: View {
} }
private var ringColor: Color { private var ringColor: Color {
// Active: always orange if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
if engine.job.status.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) } if case .failed = resolvedStatus { return AppTheme.destructive }
// Failed: red
if case .failed = engine.job.status { return AppTheme.destructive }
// Green only when ALL phone photos are accounted for (needBackup == 0)
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 { if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
return AppTheme.positive return AppTheme.positive
} }
// Default: neutral
return AppTheme.inkQuaternary.opacity(0.55) return AppTheme.inkQuaternary.opacity(0.55)
} }
@@ -608,10 +634,10 @@ struct BackupView: View {
private var actionButton: some View { private var actionButton: some View {
Group { Group {
switch engine.job.status { switch resolvedStatus {
case .idle, .completed, .failed, .cancelled: case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) { Button(action: startBackup) {
Text(engine.job.status == .idle ? "Start backup" : "Back up again") Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
} }
.buttonStyle(PrimaryButtonStyle( .buttonStyle(PrimaryButtonStyle(
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink

View File

@@ -109,7 +109,7 @@
90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; }; 90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; }; 966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; };
9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; }; 9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; }; B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; }; B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; }; BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
@@ -124,7 +124,7 @@
E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; }; E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; }; E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; }; E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; }; EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; }; F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = "<group>"; }; F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = "<group>"; };
@@ -439,7 +439,6 @@
LastUpgradeCheck = 1500; LastUpgradeCheck = 1500;
TargetAttributes = { TargetAttributes = {
009689A0ADEB4878A288991E = { 009689A0ADEB4878A288991E = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
3362521ADE965E6BA3383045 = { 3362521ADE965E6BA3383045 = {
@@ -568,6 +567,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = K8BLMMR883;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -747,6 +747,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = K8BLMMR883;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

View File

@@ -73,7 +73,7 @@ final class BackupEngine: ObservableObject {
activeTransfer = nil activeTransfer = nil
} }
// 3. Load manifest build index (manifest Data+struct released after this block) // 3. Load manifest build index (Data+struct released immediately after)
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)" let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
let index: ManifestIndex let index: ManifestIndex
if let data = try? await transfer.downloadData(at: manifestPath), if let data = try? await transfer.downloadData(at: manifestPath),
@@ -85,8 +85,23 @@ final class BackupEngine: ObservableObject {
logger.info("No manifest — will check NAS file existence per asset") logger.info("No manifest — will check NAS file existence per asset")
} }
// 4. Start job tracking // 4. Build PENDING QUEUE items not already confirmed in manifest.
job.start(totalFiles: assets.count, totalBytes: 0) // This is the denominator for progress; NOT the full gallery count.
let pendingAssets = assets.filter { asset in
!index.matches(localIdentifier: asset.localIdentifier) &&
!(index.isFilenameOnly && index.matches(filename: asset.filename))
}
logger.info("Pending queue: \(pendingAssets.count) of \(assets.count) total assets")
// If nothing is pending, finish immediately all already safe.
guard !pendingAssets.isEmpty else {
job.allAlreadySafe()
BackupStatusService.shared.refresh(force: false)
return BackupResult.empty(date: startDate)
}
// 5. Start job with PENDING count never the full gallery total.
job.start(totalFiles: pendingAssets.count, totalBytes: 0)
var uploaded = 0 var uploaded = 0
var skipped = 0 var skipped = 0
@@ -95,40 +110,24 @@ final class BackupEngine: ObservableObject {
var speedTracker = SpeedTracker() var speedTracker = SpeedTracker()
var manifestEntries: [ManifestEntry] = [] var manifestEntries: [ManifestEntry] = []
// 5. Transfer loop // 6. Transfer loop manifest check already done above, only NAS-exists + upload here.
for asset in assets { for asset in pendingAssets {
if isCancelled { break } if isCancelled { break }
while case .paused = job.status { while case .paused = job.status {
try await Task.sleep(nanoseconds: 500_000_000) try await Task.sleep(nanoseconds: 500_000_000)
} }
// Primary dedup: manifest localIdentifier (O(1), no network call)
if index.matches(localIdentifier: asset.localIdentifier) {
job.fileCompleted(skipped: true)
skipped += 1
logger.debug("Skipped (manifest id): \(asset.filename, privacy: .public)")
continue
}
// Filename fallback: bootstrap manifest has no localIdentifiers
if index.isFilenameOnly && index.matches(filename: asset.filename) {
job.fileCompleted(skipped: true)
skipped += 1
logger.debug("Skipped (manifest filename): \(asset.filename, privacy: .public)")
continue
}
// NAS file existence check never overwrite
let baseRemotePath = "\(connection.remotePath)/\(asset.filename)" let baseRemotePath = "\(connection.remotePath)/\(asset.filename)"
let fileAlreadyExists = (try? await transfer.fileExists(at: baseRemotePath)) == true
if fileAlreadyExists { // NAS file existence check skip without overwriting legacy files
if (try? await transfer.fileExists(at: baseRemotePath)) == true {
job.fileCompleted(skipped: true) job.fileCompleted(skipped: true)
skipped += 1 skipped += 1
logger.debug("Skipped (NAS exists, no-overwrite): \(asset.filename, privacy: .public)") logger.debug("Skipped (NAS exists, no-overwrite): \(asset.filename, privacy: .public)")
continue continue
} }
// Upload // Upload
do { do {
let localURL = try await photoService.exportAsset(asset) let localURL = try await photoService.exportAsset(asset)
defer { try? FileManager.default.removeItem(at: localURL) } defer { try? FileManager.default.removeItem(at: localURL) }
@@ -211,6 +210,9 @@ final class BackupEngine: ObservableObject {
func pause() { job.pause() } func pause() { job.pause() }
func resume() { job.resume() } func resume() { job.resume() }
/// Upgrades a stale .failed state to .completed when reconciliation proves needBackup == 0.
func resolveSuccess() { job.resolveSuccess() }
private func sendCompletionNotification(result: BackupResult) async { private func sendCompletionNotification(result: BackupResult) async {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete" content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete"

View File

@@ -60,6 +60,12 @@ final class BackupStatusService: NSObject, ObservableObject {
} }
} }
/// Awaitable reconciliation used by pull-to-refresh and post-failure resolution.
func refreshAndWait(force: Bool = true) async {
cancelAll()
await performRefresh(force: force)
}
/// Called by BackupEngine after a completed run with newly uploaded entries. /// Called by BackupEngine after a completed run with newly uploaded entries.
/// Applies an optimistic count update, writes the manifest in background, then reconciles. /// Applies an optimistic count update, writes the manifest in background, then reconciles.
func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) { func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {