Files
Kisani/Services/BackupEngine.swift
Robin Kutesa 1afcbaff96 perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
  Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
  Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
  O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).

Fast path reconcile:
  When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
  entirely and returns in microseconds. Falls through to full NAS path only when stale.

AutoBackupCoordinator.onActive():
  Changed force: true → force: false so the 30-second debounce prevents expensive rescans
  on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.

BackupQueueItem + queue tracking:
  BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
  speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
  always has data to show even after restart.

NotificationService:
  Centralised local notification sender replacing the inline UNMutableNotificationContent
  blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
  identifiers so same-type notifications replace each other.

SyncView redesign:
  - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
  - No live NAS directory listing (was expensive NAS connection on every tab open)
  - Shows engine.queueItems grouped into Active / Failed / Completed sections
  - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
  - Idle state shows archive count from statusService snapshot

GalleryViewModel:
  NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
  is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.

AppDelegate:
  Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00

342 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 100500 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 parse off the main actor
// The download suspends the main actor (good). JSON decode + Set<String>
// construction for a large manifest is O(N) CPU work run it in a
// detached task so we don't block touch handling or animations.
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
let spManifest = signposter.beginInterval("ManifestLoad")
let manifestData = try? await transfer.downloadData(at: manifestPath)
let index: ManifestIndex = await Task.detached(priority: .userInitiated) {
guard let data = manifestData,
let manifest = try? JSONDecoder().decode(BackupManifest.self, from: data)
else { return ManifestIndex(nasListing: []) }
return ManifestIndex(manifest: manifest)
}.value
signposter.endInterval("ManifestLoad", spManifest,
"\(index.totalCount) entries, filename-only=\(index.isFilenameOnly)")
logger.info("Manifest: \(index.totalCount) entries, filename-only=\(index.isFilenameOnly)")
try Task.checkCancellation()
// 4. Build pending queue off the main actor
// Filtering a large [PhotoAsset] is O(N) with two Set lookups per item.
// Safe to run in background: both assets and index are value-type safe.
let spFilter = signposter.beginInterval("BuildPendingQueue")
let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) {
assets.filter { asset in
!index.matches(localIdentifier: asset.localIdentifier) &&
!(index.isFilenameOnly && index.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)
let spUpload = signposter.beginInterval("UploadLoop", "\(pendingAssets.count) files")
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)"
// Mark as uploading
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
}
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
}
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
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()
))
} catch {
logger.error("Upload 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
}
}