SMBClient throws ErrorResponse with NTStatus.objectNameCollision (0xC0000035) when a file already exists on the NAS. BackupError.uploadFailed wraps this, hiding the real cause from the retry/failure logic. Add isAlreadyExistsError() that unwraps BackupError.uploadFailed and checks the underlying error for "object name collision", "already exist", or POSIX EEXIST (code 17). When detected inside the retry catch block, the asset is immediately marked as skipped and added to manifestEntries — no 3-retry penalty (saves up to 6s per already-existing file), no false failure count. The post-retry fileExists fallback is retained for edge cases where the file lands on NAS but the error wasn't a collision (e.g. connection reset after partial upload). Also switch BackupQueueItem.UploadError to store the underlying transport error's domain and description instead of the BackupError wrapper text, so the SyncView failure list shows the real reason (e.g. "Access Denied", "IO Timeout") rather than the generic "Failed to upload <file>". Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
494 lines
22 KiB
Swift
494 lines
22 KiB
Swift
import Foundation
|
||
import Photos
|
||
import os.log
|
||
|
||
private let logger = Logger(subsystem: "com.albert.nasbackup", category: "BackupEngine")
|
||
private let signposter = OSSignposter(subsystem: "com.albert.nasbackup", category: "BackupEngine")
|
||
|
||
@MainActor
|
||
final class BackupEngine: ObservableObject {
|
||
static let shared = BackupEngine()
|
||
|
||
@Published private(set) var job: BackupJob = BackupJob()
|
||
@Published private(set) var queueItems: [BackupQueueItem] = []
|
||
|
||
private let photoService: PhotoLibraryProtocol
|
||
private let transferFactory: (NASProtocol) -> any NASTransferProtocol
|
||
private var activeTransfer: (any NASTransferProtocol)?
|
||
private var isCancelled = false
|
||
|
||
private static let queueCacheURL: URL = {
|
||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||
return caches.appendingPathComponent("kisani_last_queue.json")
|
||
}()
|
||
|
||
private init(
|
||
photos: PhotoLibraryProtocol = PhotoLibraryService(),
|
||
transferFactory: @escaping (NASProtocol) -> any NASTransferProtocol = { proto in
|
||
switch proto {
|
||
case .smb: return SMBService()
|
||
case .sftp: return SFTPService()
|
||
}
|
||
}
|
||
) {
|
||
self.photoService = photos
|
||
self.transferFactory = transferFactory
|
||
// Load last session's queue so SyncView has data to show on next launch.
|
||
if let data = try? Data(contentsOf: Self.queueCacheURL),
|
||
let items = try? JSONDecoder.kisani.decode([BackupQueueItem].self, from: data) {
|
||
self.queueItems = items
|
||
}
|
||
}
|
||
|
||
static func testInstance(
|
||
transfer: any NASTransferProtocol,
|
||
photos: PhotoLibraryProtocol
|
||
) -> BackupEngine {
|
||
BackupEngine(photos: photos, transferFactory: { _ in transfer })
|
||
}
|
||
|
||
func run(
|
||
connection: NASConnection,
|
||
filter: BackupFilter,
|
||
triggeredByLAN: Bool = false
|
||
) async throws -> BackupResult {
|
||
isCancelled = false
|
||
let startDate = Date()
|
||
let store = ConnectionStore.shared
|
||
let lan = LANMonitor.shared
|
||
|
||
if !triggeredByLAN && lan.isOnCellular && !store.allowCellularBackup {
|
||
throw BackupError.networkUnavailable
|
||
}
|
||
|
||
let host = resolveHost(connection: connection, store: store, lan: lan)
|
||
|
||
// ── 1. Fetch assets off the main actor ─────────────────────────────
|
||
// fetchAssets() enumerates every PHAsset and builds a [PhotoAsset] array.
|
||
// For a library of 10 k+ assets this is 100–500 ms of synchronous work.
|
||
// Running it in a detached task keeps the main thread free for animations.
|
||
job.prepare()
|
||
let photoSvc = self.photoService
|
||
let spFetch = signposter.beginInterval("FetchAssets")
|
||
let assets: [PhotoAsset] = await Task.detached(priority: .userInitiated) {
|
||
photoSvc.fetchAssets(filter: filter)
|
||
}.value
|
||
signposter.endInterval("FetchAssets", spFetch, "\(assets.count) assets")
|
||
logger.info("Photo fetch: \(assets.count) assets in library")
|
||
|
||
try Task.checkCancellation()
|
||
|
||
// ── 2. Connect to NAS ───────────────────────────────────────────────
|
||
// Network I/O suspends the main actor — UI stays live the whole time.
|
||
let transfer = transferFactory(connection.nasProtocol)
|
||
let spConnect = signposter.beginInterval("NASConnect")
|
||
try await transfer.connect(
|
||
to: host, port: connection.port,
|
||
username: connection.username, password: connection.password
|
||
)
|
||
signposter.endInterval("NASConnect", spConnect)
|
||
activeTransfer = transfer
|
||
defer { transfer.disconnect(); activeTransfer = nil }
|
||
|
||
try Task.checkCancellation()
|
||
|
||
// ── 3. Load manifest ───────────────────────────────────────────────
|
||
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
|
||
let spManifest = signposter.beginInterval("ManifestLoad")
|
||
|
||
let manifestData = try? await transfer.downloadData(at: manifestPath)
|
||
|
||
// Decode NAS manifest off the main actor.
|
||
let nasManifest: BackupManifest? = await Task.detached(priority: .userInitiated) {
|
||
guard let data = manifestData else { return nil }
|
||
return try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
|
||
}.value
|
||
|
||
// Use the manifest with more entries as the authoritative base.
|
||
// CRITICAL: if the NAS manifest has fewer entries than the local cache (e.g. 1 vs 264),
|
||
// the cache preserves accumulated history and must win. Overwriting the cache with a
|
||
// smaller NAS manifest destroys history and causes every asset to re-upload.
|
||
let cachedManifest = await NASManifestCache.shared.manifest
|
||
let cacheCount = cachedManifest?.entries.count ?? 0
|
||
let nasCount = nasManifest?.entries.count ?? 0
|
||
|
||
let baseManifest: BackupManifest
|
||
if nasCount > 0, nasCount >= cacheCount {
|
||
baseManifest = nasManifest!
|
||
await NASManifestCache.shared.update(nasManifest!)
|
||
logger.info("Manifest from NAS — \(nasCount) entries")
|
||
} else if cacheCount > 0 {
|
||
baseManifest = cachedManifest!
|
||
logger.info("Manifest from cache — \(cacheCount) entries (NAS had \(nasCount))")
|
||
} else {
|
||
// Genuinely first backup — no manifest anywhere.
|
||
baseManifest = nasManifest ?? BackupManifest()
|
||
logger.info("Manifest: starting fresh — \(baseManifest.entries.count) entries")
|
||
}
|
||
let baseIndex = ManifestIndex(manifest: baseManifest)
|
||
|
||
signposter.endInterval("ManifestLoad", spManifest, "\(baseIndex.totalCount) entries")
|
||
logger.info("Manifest base: \(baseIndex.totalCount) entries, isFilenameOnly=\(baseIndex.isFilenameOnly)")
|
||
|
||
try Task.checkCancellation()
|
||
|
||
// ── 4. Build pending queue off the main actor ──────────────────────
|
||
let spFilter = signposter.beginInterval("BuildPendingQueue")
|
||
let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) {
|
||
assets.filter { asset in
|
||
!baseIndex.matches(localIdentifier: asset.localIdentifier) &&
|
||
!(baseIndex.isFilenameOnly && baseIndex.matches(filename: asset.filename)) &&
|
||
!baseIndex.matchesUnclaimed(filename: asset.filename)
|
||
}
|
||
}.value
|
||
signposter.endInterval("BuildPendingQueue", spFilter, "\(pendingAssets.count) pending")
|
||
logger.info("Pending queue: \(pendingAssets.count) of \(assets.count) total")
|
||
|
||
guard !pendingAssets.isEmpty else {
|
||
job.allAlreadySafe()
|
||
BackupStatusService.shared.refresh(force: false)
|
||
return BackupResult.empty(date: startDate)
|
||
}
|
||
|
||
// ── 5. Upload loop ─────────────────────────────────────────────────
|
||
job.start(totalFiles: pendingAssets.count, totalBytes: 0)
|
||
NotificationService.notifyBackupStarted(count: pendingAssets.count)
|
||
|
||
// Build initial queue (all queued) for SyncView
|
||
queueItems = pendingAssets.map { asset in
|
||
BackupQueueItem(
|
||
id: asset.localIdentifier,
|
||
filename: asset.filename,
|
||
nasPath: "\(connection.remotePath)/\(asset.filename)",
|
||
status: .queued, bytesUploaded: 0, totalBytes: 0, speedBytesPerSec: 0,
|
||
error: nil, retryCount: 0, queuedAt: Date(), completedAt: nil
|
||
)
|
||
}
|
||
|
||
var uploaded = 0
|
||
var skipped = 0
|
||
var failed = 0
|
||
var totalBytes: Int64 = 0
|
||
var speedTracker = SpeedTracker()
|
||
var throttle = ProgressThrottle(hz: 8)
|
||
var manifestEntries: [ManifestEntry] = []
|
||
manifestEntries.reserveCapacity(pendingAssets.count)
|
||
var lastCheckpointAt = 0 // uploaded count at last manifest checkpoint write
|
||
let checkpointInterval = 25
|
||
|
||
let spUpload = signposter.beginInterval("UploadLoop", "\(pendingAssets.count) files")
|
||
|
||
let maxRetries = 3
|
||
|
||
for (assetIdx, asset) in pendingAssets.enumerated() {
|
||
if isCancelled { break }
|
||
while case .paused = job.status {
|
||
try await Task.sleep(nanoseconds: 500_000_000)
|
||
}
|
||
|
||
let remotePath = "\(connection.remotePath)/\(asset.filename)"
|
||
|
||
if assetIdx < queueItems.count {
|
||
queueItems[assetIdx].status = .uploading
|
||
}
|
||
|
||
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()
|
||
}
|
||
// Record in manifest even though we didn't upload — the file is on NAS.
|
||
// Without this, skipped files stay "pending" forever and trigger backup loops.
|
||
manifestEntries.append(ManifestEntry(
|
||
localIdentifier: asset.localIdentifier,
|
||
filename: asset.filename,
|
||
creationDate: asset.creationDate,
|
||
fileSize: 0,
|
||
remotePath: remotePath,
|
||
uploadedAt: Date()
|
||
))
|
||
logger.debug("Skipped (NAS exists): \(asset.filename, privacy: .public)")
|
||
continue
|
||
}
|
||
|
||
var uploadSucceeded = false
|
||
var lastUploadError: Error?
|
||
|
||
for attempt in 0..<maxRetries {
|
||
if isCancelled { break }
|
||
|
||
if attempt > 0 {
|
||
// Exponential backoff: 2s, 4s before retries 2 and 3
|
||
let backoffNs = UInt64(pow(2.0, Double(attempt)) * 1_000_000_000)
|
||
try? await Task.sleep(nanoseconds: backoffNs)
|
||
if assetIdx < queueItems.count {
|
||
queueItems[assetIdx].retryCount = attempt
|
||
queueItems[assetIdx].status = .uploading
|
||
}
|
||
logger.info("Retry \(attempt)/\(maxRetries - 1) for \(asset.filename, privacy: .public)")
|
||
}
|
||
|
||
do {
|
||
let spFile = signposter.beginInterval("UploadFile",
|
||
"\(asset.filename, privacy: .public)")
|
||
let localURL = try await photoService.exportAsset(asset)
|
||
defer { try? FileManager.default.removeItem(at: localURL) }
|
||
|
||
let fileSize = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
|
||
.flatMap { Int64($0) } ?? 0
|
||
let bytesSoFar = totalBytes
|
||
let capturedIdx = assetIdx
|
||
|
||
if capturedIdx < queueItems.count {
|
||
queueItems[capturedIdx].totalBytes = fileSize
|
||
queueItems[capturedIdx].bytesUploaded = 0
|
||
}
|
||
|
||
try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, _ in
|
||
let speed = speedTracker.update(bytesSent: sent)
|
||
guard throttle.shouldUpdate() else { return }
|
||
Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
self.job.updateProgress(
|
||
fileName: asset.filename,
|
||
fileSize: fileSize,
|
||
bytesTransferred: bytesSoFar + sent,
|
||
speed: speed
|
||
)
|
||
if capturedIdx < self.queueItems.count {
|
||
self.queueItems[capturedIdx].bytesUploaded = sent
|
||
self.queueItems[capturedIdx].speedBytesPerSec = speed
|
||
}
|
||
}
|
||
}
|
||
|
||
signposter.endInterval("UploadFile", spFile, "\(fileSize) bytes")
|
||
totalBytes += fileSize
|
||
job.fileCompleted(skipped: false)
|
||
uploaded += 1
|
||
uploadSucceeded = true
|
||
|
||
if assetIdx < queueItems.count {
|
||
queueItems[assetIdx].status = .uploaded
|
||
queueItems[assetIdx].bytesUploaded = fileSize
|
||
queueItems[assetIdx].completedAt = Date()
|
||
}
|
||
|
||
manifestEntries.append(ManifestEntry(
|
||
localIdentifier: asset.localIdentifier,
|
||
filename: asset.filename,
|
||
creationDate: asset.creationDate,
|
||
fileSize: fileSize,
|
||
remotePath: remotePath,
|
||
uploadedAt: Date()
|
||
))
|
||
|
||
// Checkpoint write every N uploads — protects against interruption.
|
||
// Uses the same open connection so no extra connect/disconnect overhead.
|
||
if uploaded - lastCheckpointAt >= checkpointInterval {
|
||
lastCheckpointAt = uploaded
|
||
var checkpoint = baseManifest
|
||
checkpoint.merge(entries: manifestEntries)
|
||
if let encoded = try? JSONEncoder.kisani.encode(checkpoint) {
|
||
try? await transfer.writeData(encoded, to: manifestPath)
|
||
await NASManifestCache.shared.update(checkpoint)
|
||
logger.info("Manifest checkpoint: \(checkpoint.entries.count) total entries")
|
||
}
|
||
}
|
||
} catch {
|
||
// "Object Name Collision" (NTStatus 0xC0000035) means the file is already
|
||
// on the NAS. Retrying won't help — treat it as safe immediately.
|
||
if isAlreadyExistsError(error) {
|
||
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("Collision — file already on NAS, marking safe: \(asset.filename, privacy: .public)")
|
||
uploadSucceeded = true
|
||
break
|
||
}
|
||
lastUploadError = error
|
||
logger.warning("Upload attempt \(attempt + 1)/\(maxRetries) failed for \(asset.filename, privacy: .public): \(underlyingDescription(error), privacy: .public)")
|
||
}
|
||
|
||
if uploadSucceeded { break }
|
||
}
|
||
|
||
if !uploadSucceeded, let error = lastUploadError {
|
||
// Last-resort check: maybe the file landed on NAS despite the errors
|
||
// (e.g. fileExists threw transiently on the pre-check and the upload
|
||
// actually succeeded before the connection reset).
|
||
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("Post-retry check — file on NAS, marking safe: \(asset.filename, privacy: .public)")
|
||
} else {
|
||
let reason = underlyingDescription(error)
|
||
logger.error("Upload permanently failed for \(asset.filename, privacy: .public): \(reason, privacy: .public)")
|
||
job.fileFailed()
|
||
failed += 1
|
||
if assetIdx < queueItems.count {
|
||
queueItems[assetIdx].status = .failed
|
||
queueItems[assetIdx].completedAt = Date()
|
||
queueItems[assetIdx].error = BackupQueueItem.UploadError(
|
||
code: underlyingDomain(error),
|
||
message: reason,
|
||
timestamp: Date()
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed")
|
||
|
||
// Keep directoryCount current without a full rescan.
|
||
if uploaded > 0 {
|
||
await NASManifestCache.shared.addToDirectoryCount(uploaded)
|
||
}
|
||
|
||
let duration = Date().timeIntervalSince(startDate)
|
||
let result = BackupResult(
|
||
uploadedCount: uploaded,
|
||
skippedCount: skipped,
|
||
failedCount: failed,
|
||
duration: duration,
|
||
totalBytes: totalBytes,
|
||
date: startDate
|
||
)
|
||
|
||
job.finish(result: result)
|
||
BackupStatusService.shared.refreshAfterBackup(entries: manifestEntries, connection: connection)
|
||
|
||
let entry = BackupHistoryEntry(result: result, nasHost: host, triggeredByLAN: triggeredByLAN)
|
||
store.appendHistoryEntry(entry)
|
||
|
||
// Notifications
|
||
if result.failedCount > 0 {
|
||
NotificationService.notifyBackupFailed(count: result.failedCount)
|
||
} else {
|
||
NotificationService.notifyBackupCompleted(
|
||
uploaded: result.uploadedCount,
|
||
total: BackupStatusService.shared.snapshot.phoneTotal
|
||
)
|
||
}
|
||
|
||
// Persist queue so SyncView shows last session on next launch
|
||
let snapshot = queueItems
|
||
Task.detached(priority: .utility) { [snapshot, url = Self.queueCacheURL] in
|
||
guard let data = try? JSONEncoder.kisani.encode(snapshot) else { return }
|
||
try? data.write(to: url, options: .atomic)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// Returns Tailscale host when off trusted LAN and tunnel is active.
|
||
private func resolveHost(connection: NASConnection, store: ConnectionStore, lan: LANMonitor) -> String {
|
||
let onTrustedLAN = store.trustedSSIDs.contains(lan.currentSSID ?? "")
|
||
guard !onTrustedLAN,
|
||
store.useTailscaleWhenRemote,
|
||
!store.tailscaleHost.isEmpty,
|
||
lan.isTailscaleActive else { return connection.host }
|
||
return store.tailscaleHost
|
||
}
|
||
|
||
func cancel() { isCancelled = true; job.cancel() }
|
||
func pause() { job.pause() }
|
||
func resume() { job.resume() }
|
||
|
||
func resolveSuccess() { job.resolveSuccess() }
|
||
|
||
}
|
||
|
||
// MARK: — Error classification helpers
|
||
|
||
/// True when the error indicates the remote file already exists.
|
||
/// SMBClient surfaces this as ErrorResponse with NTStatus.objectNameCollision (0xC0000035),
|
||
/// whose localizedDescription is "Object Name Collision".
|
||
/// SFTP and POSIX use EEXIST (code 17) or the string "already exists".
|
||
private func isAlreadyExistsError(_ error: Error) -> Bool {
|
||
let base = unwrapUploadError(error)
|
||
let desc = base.localizedDescription.lowercased()
|
||
return desc.contains("object name collision") ||
|
||
desc.contains("already exist") ||
|
||
desc.contains("file exist") ||
|
||
(base as NSError).code == 17 // POSIX EEXIST
|
||
}
|
||
|
||
/// Returns the underlying transport error, unwrapping BackupError.uploadFailed if present.
|
||
private func unwrapUploadError(_ error: Error) -> Error {
|
||
if case let BackupError.uploadFailed(_, underlying) = error { return underlying }
|
||
return error
|
||
}
|
||
|
||
/// Human-readable description from the underlying transport error, not the wrapper.
|
||
private func underlyingDescription(_ error: Error) -> String {
|
||
unwrapUploadError(error).localizedDescription
|
||
}
|
||
|
||
/// NSError domain from the underlying transport error.
|
||
private func underlyingDomain(_ error: Error) -> String {
|
||
(unwrapUploadError(error) as NSError).domain
|
||
}
|
||
|
||
// MARK: — SpeedTracker
|
||
|
||
private struct SpeedTracker {
|
||
private var lastBytes: Int64 = 0
|
||
private var lastTime: Date = Date()
|
||
|
||
mutating func update(bytesSent: Int64) -> Double {
|
||
let now = Date()
|
||
let elapsed = now.timeIntervalSince(lastTime)
|
||
guard elapsed > 0.1 else { return 0 }
|
||
let speed = Double(bytesSent - lastBytes) / elapsed
|
||
lastBytes = bytesSent
|
||
lastTime = now
|
||
return max(0, speed)
|
||
}
|
||
}
|
||
|
||
// MARK: — ProgressThrottle
|
||
|
||
/// Limits the rate at which progress callbacks trigger @MainActor UI updates.
|
||
/// Without this, SMB/SFTP stacks fire hundreds of callbacks per second, causing
|
||
/// SwiftUI to redraw the full view tree on every tick.
|
||
private struct ProgressThrottle {
|
||
private var lastUpdate: Date = .distantPast
|
||
private let minInterval: TimeInterval
|
||
|
||
init(hz: Double = 8) { minInterval = 1.0 / max(1, hz) }
|
||
|
||
mutating func shouldUpdate() -> Bool {
|
||
let now = Date()
|
||
guard now.timeIntervalSince(lastUpdate) >= minInterval else { return false }
|
||
lastUpdate = now
|
||
return true
|
||
}
|
||
}
|