Fix backup state logic, ring colors, Already Safe invariant, and dedup

BackupJob: fix finish() — failedCount > 0 now correctly sets .failed
instead of .completed in both branches.

BackupEngine: replace filename-only fileExists check with a 3-layer
dedup strategy. For each asset: (1) check ManifestIndex by localIdentifier
O(1); (2) filename fallback for bootstrap manifests; (3) NAS fileExists
for files not yet in manifest. Never overwrite an existing NAS file.
Manifest index is built once after connect and held in a local let for
the loop — Data+BackupManifest are released immediately after.

BackupView — ring color:
  active (running/preparing/paused) → orange always
  failed → destructive
  needBackup == 0 && phoneTotal > 0 → green
  default → neutral gray
  Ring is no longer permanently green after .completed.

BackupView — CTA button:
  Default color is always black (AppTheme.ink).
  On .completed: briefly flashes green for 2.5 s then smoothly
  reverts to black. ctaFlashGreen state drives the color.

BackupView — Already Safe invariant:
  alreadySafeDisplayCount = min(base + uploadedFiles, phoneTotal)
  during active backup; min(base, phoneTotal) otherwise.
  Adding skippedFiles was removed — they are already counted in
  snapshot.alreadySafe from the last reconcile.
  notBackedUpDisplayCount is derived from alreadySafeDisplayCount to
  keep the invariant alreadySafe + needBackup == phoneTotal.

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:30:52 +03:00
parent c11ce6d644
commit 819f2b949e
3 changed files with 75 additions and 36 deletions

View File

@@ -73,7 +73,19 @@ final class BackupEngine: ObservableObject {
activeTransfer = nil
}
// 3. Start job tracking
// 3. Load manifest build index (manifest Data+struct released after this block)
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
let index: ManifestIndex
if let data = try? await transfer.downloadData(at: manifestPath),
let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data) {
index = ManifestIndex(manifest: manifest)
logger.info("Manifest loaded — \(index.totalCount) entries, filename-only=\(index.isFilenameOnly)")
} else {
index = ManifestIndex(nasListing: [])
logger.info("No manifest — will check NAS file existence per asset")
}
// 4. Start job tracking
job.start(totalFiles: assets.count, totalBytes: 0)
var uploaded = 0
@@ -83,21 +95,40 @@ final class BackupEngine: ObservableObject {
var speedTracker = SpeedTracker()
var manifestEntries: [ManifestEntry] = []
// 4. Transfer loop
// 5. Transfer loop
for asset in assets {
if isCancelled { break }
while case .paused = job.status {
try await Task.sleep(nanoseconds: 500_000_000)
}
let remotePath = "\(connection.remotePath)/\(asset.filename)"
if filter.newFilesOnly, (try? await transfer.fileExists(at: remotePath)) == true {
// 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 fileAlreadyExists = (try? await transfer.fileExists(at: baseRemotePath)) == true
if fileAlreadyExists {
job.fileCompleted(skipped: true)
skipped += 1
logger.debug("Skipped (NAS exists, no-overwrite): \(asset.filename, privacy: .public)")
continue
}
// Upload
do {
let localURL = try await photoService.exportAsset(asset)
defer { try? FileManager.default.removeItem(at: localURL) }
@@ -105,7 +136,7 @@ final class BackupEngine: ObservableObject {
let fileSize = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
.flatMap { Int64($0) } ?? 0
try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, total in
try await transfer.upload(localURL: localURL, remotePath: baseRemotePath) { sent, total in
let speed = speedTracker.update(bytesSent: sent)
Task { @MainActor [weak self] in
self?.job.updateProgress(
@@ -126,7 +157,7 @@ final class BackupEngine: ObservableObject {
filename: asset.filename,
creationDate: asset.creationDate,
fileSize: fileSize,
remotePath: remotePath,
remotePath: baseRemotePath,
uploadedAt: Date()
))
} catch {