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>
342 lines
14 KiB
Swift
342 lines
14 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 — 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
|
||
}
|
||
}
|