diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 82259f3..170e02c 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -153,18 +153,11 @@ struct BackupView: View { try? await Task.sleep(nanoseconds: 500_000_000) 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 } - } - } } + // .failed auto-resolve is owned by AutoBackupCoordinator.handleReconciliationComplete. + // It waits for refreshAfterBackup's writeManifest to finish before reconciling, then + // calls engine.resolveSuccess() when needBackup == 0, which re-triggers .completed above. + // Calling refreshAndWait here would race with writeManifest and read a stale NAS manifest. } } diff --git a/Services/AutoBackupCoordinator.swift b/Services/AutoBackupCoordinator.swift index 8c7ac02..7f9e26f 100644 --- a/Services/AutoBackupCoordinator.swift +++ b/Services/AutoBackupCoordinator.swift @@ -33,6 +33,11 @@ final class AutoBackupCoordinator: ObservableObject { private var lastAutoCheckAt: Date = .distantPast private var recheckTimer: AnyCancellable? + // One-shot flag armed by onActive()/handleNASReachable() and consumed by + // handleReconciliationComplete(). Prevents the post-backup refresh cycle from + // re-triggering auto-backup — only an explicit app-open or LAN-join should do that. + private var backupTriggerArmed = false + private let engine = BackupEngine.shared private let statusService = BackupStatusService.shared private let store = ConnectionStore.shared @@ -62,6 +67,7 @@ final class AutoBackupCoordinator: ObservableObject { Task { @MainActor [weak self] in self?.recheckTimer = nil self?.lastAutoCheckAt = .distantPast + self?.backupTriggerArmed = false log.debug("App resigned — recheck timer stopped, debounce reset") } } @@ -120,6 +126,7 @@ final class AutoBackupCoordinator: ObservableObject { } lastAutoCheckAt = Date() + backupTriggerArmed = true phase = .checking startPeriodicRecheckIfNeeded() log.info("onActive: trigger=\(lanTriggered ? "LAN" : "appOpen") — status check starting") @@ -134,13 +141,25 @@ final class AutoBackupCoordinator: ObservableObject { // No destination configured yet guard let conn = store.savedConnection, conn.remotePath != "/" else { phase = .idle + backupTriggerArmed = false return } - // Nothing to back up — clear any stale state + // Auto-resolve: engine reported failure but reconcile shows all assets are safe. + // This fires after refreshAfterBackup's writeManifest completes and updates the manifest, + // at which point needBackup correctly reflects what's still missing. + if case .failed = engine.job.status { + if snapshot.needBackup == 0 { + engine.resolveSuccess() + log.info("Auto-resolved .failed → .completed (needBackup == 0 after reconcile)") + } + } + + // Nothing to back up guard snapshot.needBackup > 0 else { phase = .idle pendingAtDisconnect = 0 + backupTriggerArmed = false return } @@ -148,23 +167,35 @@ final class AutoBackupCoordinator: ObservableObject { if snapshot.connectionState == .offline { pendingAtDisconnect = snapshot.needBackup phase = .disconnected + backupTriggerArmed = false log.info("NAS offline — \(snapshot.needBackup) items queued") return } + // Only fire auto-backup when explicitly armed by onActive() or handleNASReachable(). + // Post-backup reconciles (from refreshAfterBackup) must not re-trigger — that's the + // source of the restart loop when alreadySafe is temporarily wrong. + guard backupTriggerArmed else { + phase = .idle + log.info("handleReconciliationComplete: trigger not armed — skipping auto-backup") + return + } + // Gate auto-backup on trigger source: - // LAN join → autoBackupEnabled (lanTriggerEnabled guards the SSID check in LANMonitor) - // App open → autoBackupOnOpen (off by default; requires explicit opt-in) + // LAN join → autoBackupEnabled + // App open → autoBackupOnOpen (off by default) let autoAllowed = lastTriggerWasLAN ? store.autoBackupEnabled : store.autoBackupOnOpen guard autoAllowed else { phase = .idle + backupTriggerArmed = false log.info("Auto-backup gated — trigger=\(self.lastTriggerWasLAN ? "LAN" : "appOpen") enabled=\(autoAllowed)") return } + backupTriggerArmed = false triggerAutoBackupIfNeeded(conn: conn) } diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index 7e5f03c..dad2ba3 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -306,17 +306,38 @@ final class BackupEngine: ObservableObject { } if !uploadSucceeded, let error = lastUploadError { - logger.error("Upload permanently failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") - job.fileFailed() - failed += 1 - if assetIdx < queueItems.count { - queueItems[assetIdx].status = .failed - queueItems[assetIdx].completedAt = Date() - queueItems[assetIdx].error = BackupQueueItem.UploadError( - code: (error as NSError).domain, - message: error.localizedDescription, - timestamp: Date() - ) + // Before recording a failure, verify the file isn't already on NAS. + // fileExists() may have returned nil earlier due to a transient SMB error; + // if it's actually there, the asset is safe — count it as skipped, not failed. + if (try? await transfer.fileExists(at: remotePath)) == true { + job.fileCompleted(skipped: true) + skipped += 1 + if assetIdx < queueItems.count { + queueItems[assetIdx].status = .skipped + queueItems[assetIdx].completedAt = Date() + } + manifestEntries.append(ManifestEntry( + localIdentifier: asset.localIdentifier, + filename: asset.filename, + creationDate: asset.creationDate, + fileSize: 0, + remotePath: remotePath, + uploadedAt: Date() + )) + logger.info("Upload failed but file confirmed on NAS — marking safe: \(asset.filename, privacy: .public)") + } else { + logger.error("Upload permanently failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)") + job.fileFailed() + failed += 1 + if assetIdx < queueItems.count { + queueItems[assetIdx].status = .failed + queueItems[assetIdx].completedAt = Date() + queueItems[assetIdx].error = BackupQueueItem.UploadError( + code: (error as NSError).domain, + message: error.localizedDescription, + timestamp: Date() + ) + } } } }