Two root causes: 1. Backup interrupted before loop completion = manifest write never fires, leaving uploaded files as NAS orphans invisible to gallery and dedup. 2. Manifest only written once at loop end, so any crash loses all progress. Fixes: - On each backup run, list the NAS directory and merge any files not tracked in the manifest (orphaned from prior interrupted sessions). The healed manifest is written back to NAS and NASManifestCache before uploads start, so the gallery reflects actual NAS contents immediately on next open. - Incremental checkpoint writes every 25 uploads using the already-open transfer connection — interrupted backups now preserve progress in blocks of 25 instead of losing everything. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
427 lines
19 KiB
Swift
427 lines
19 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 + heal orphaned files ─────────────────────────
|
||
// After any interrupted backup, files may sit on NAS without a manifest
|
||
// entry. We list the directory and merge any untracked files so the
|
||
// gallery and dedup logic reflect actual NAS contents.
|
||
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
|
||
let spManifest = signposter.beginInterval("ManifestLoad")
|
||
|
||
let manifestData = try? await transfer.downloadData(at: manifestPath)
|
||
let nasItems = (try? await transfer.listDirectory(at: connection.remotePath)) ?? []
|
||
|
||
// Decode + heal off main actor (CPU-bound work).
|
||
struct ManifestLoadResult { var index: ManifestIndex; var manifest: BackupManifest; var orphanCount: Int }
|
||
var loadResult: ManifestLoadResult = await Task.detached(priority: .userInitiated) {
|
||
var manifest: BackupManifest
|
||
if let data = manifestData,
|
||
let decoded = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) {
|
||
manifest = decoded
|
||
} else {
|
||
manifest = BackupManifest.buildFromNASListing(nasItems)
|
||
return ManifestLoadResult(index: ManifestIndex(manifest: manifest), manifest: manifest, orphanCount: 0)
|
||
}
|
||
|
||
// Heal: find NAS files not tracked in the manifest (orphans from
|
||
// backups that were interrupted before the manifest write step).
|
||
let knownFilenames = Set(manifest.entries.map { $0.filename.lowercased() })
|
||
let mediaExts: Set<String> = ["jpg","jpeg","png","heic","heif","gif","webp","bmp",
|
||
"tiff","tif","raw","dng","cr2","nef","arw",
|
||
"mp4","mov","m4v","avi","mkv"]
|
||
let orphans: [ManifestEntry] = nasItems.compactMap { item in
|
||
guard !item.isDirectory,
|
||
item.name != BackupManifest.remoteFilename,
|
||
!item.name.hasPrefix("."),
|
||
mediaExts.contains((item.name as NSString).pathExtension.lowercased()),
|
||
!knownFilenames.contains(item.name.lowercased()) else { return nil }
|
||
return ManifestEntry(
|
||
localIdentifier: "",
|
||
filename: item.name,
|
||
creationDate: item.modifiedDate,
|
||
fileSize: item.size,
|
||
remotePath: item.path,
|
||
uploadedAt: item.modifiedDate ?? Date()
|
||
)
|
||
}
|
||
if !orphans.isEmpty { manifest.merge(entries: orphans) }
|
||
return ManifestLoadResult(index: ManifestIndex(manifest: manifest), manifest: manifest, orphanCount: orphans.count)
|
||
}.value
|
||
|
||
signposter.endInterval("ManifestLoad", spManifest,
|
||
"\(loadResult.index.totalCount) entries, orphans=\(loadResult.orphanCount)")
|
||
logger.info("Manifest: \(loadResult.index.totalCount) entries (\(loadResult.orphanCount) orphans healed)")
|
||
|
||
// If the manifest gained entries from healing, write it back to NAS now
|
||
// so the gallery reflects actual NAS contents even before uploads start.
|
||
if loadResult.orphanCount > 0, let encoded = try? JSONEncoder.kisani.encode(loadResult.manifest) {
|
||
try? await transfer.writeData(encoded, to: manifestPath)
|
||
}
|
||
|
||
// Always seed local cache with the current (healed) state.
|
||
await NASManifestCache.shared.update(loadResult.manifest)
|
||
|
||
try Task.checkCancellation()
|
||
|
||
// ── 4. Build pending queue off the main actor ──────────────────────
|
||
let spFilter = signposter.beginInterval("BuildPendingQueue")
|
||
let currentIndex = loadResult.index
|
||
let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) {
|
||
assets.filter { asset in
|
||
!currentIndex.matches(localIdentifier: asset.localIdentifier) &&
|
||
!(currentIndex.isFilenameOnly && currentIndex.matches(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()
|
||
}
|
||
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 = loadResult.manifest
|
||
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 {
|
||
lastUploadError = error
|
||
logger.warning("Upload attempt \(attempt + 1)/\(maxRetries) failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||
}
|
||
|
||
if uploadSucceeded { break }
|
||
}
|
||
|
||
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()
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
signposter.endInterval("UploadLoop", spUpload, "\(uploaded) uploaded, \(failed) failed")
|
||
|
||
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: — 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
|
||
}
|
||
}
|