BackupEngine: - fetchAssets() runs in Task.detached — prevents 100-500ms UI freeze during PHFetchResult enumeration on large libraries - Manifest JSON decode + ManifestIndex Set construction run in Task.detached - pendingAssets filter runs in Task.detached — O(N) array filter off main thread - Progress updates throttled to 8 Hz via ProgressThrottle — eliminates the continuous SwiftUI full-tree redraws caused by per-byte upload callbacks - bytesSoFar captured by value in upload closure — avoids mutable var capture - reserveCapacity on manifestEntries array - os_signpost intervals on FetchAssets, NASConnect, ManifestLoad, BuildPendingQueue, UploadLoop, UploadFile for Instruments profiling BackupStatusService: - countSafe() marked nonisolated — runs in Task.detached off main actor, keeping touch/animation responsive during PHFetchResult batch enumeration - ManifestIndex JSON decode + Set construction run in Task.detached - Bootstrap ManifestIndex(nasListing:) construction off main actor - writeManifest JSON decode/encode/merge off main actor - os_signpost intervals on Reconcile, ManifestLoad, CountSafe Model types: - ManifestIndex, ManifestEntry, PhotoAsset, BackupFilter marked Sendable — safe to pass across task boundaries without warnings Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
283 lines
12 KiB
Swift
283 lines
12 KiB
Swift
import Foundation
|
||
import UserNotifications
|
||
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()
|
||
|
||
private let photoService: PhotoLibraryProtocol
|
||
private let transferFactory: (NASProtocol) -> any NASTransferProtocol
|
||
private var activeTransfer: (any NASTransferProtocol)?
|
||
private var isCancelled = false
|
||
|
||
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
|
||
}
|
||
|
||
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 ─────────────────────────────────────────────────
|
||
// The main actor is released at every `await`. Progress UI is throttled
|
||
// to 8 Hz — without throttling, one Task { @MainActor } is fired per
|
||
// progress callback which causes continuous SwiftUI full-tree re-renders.
|
||
job.start(totalFiles: pendingAssets.count, totalBytes: 0)
|
||
|
||
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 asset in pendingAssets {
|
||
if isCancelled { break }
|
||
while case .paused = job.status {
|
||
try await Task.sleep(nanoseconds: 500_000_000)
|
||
}
|
||
|
||
let remotePath = "\(connection.remotePath)/\(asset.filename)"
|
||
|
||
if (try? await transfer.fileExists(at: remotePath)) == true {
|
||
job.fileCompleted(skipped: true)
|
||
skipped += 1
|
||
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
|
||
// Capture totalBytes by value so the progress closure doesn't capture
|
||
// the mutable var — avoids a data race warning and is semantically correct.
|
||
let bytesSoFar = totalBytes
|
||
|
||
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
|
||
self?.job.updateProgress(
|
||
fileName: asset.filename,
|
||
fileSize: fileSize,
|
||
bytesTransferred: bytesSoFar + sent,
|
||
speed: speed
|
||
)
|
||
}
|
||
}
|
||
|
||
signposter.endInterval("UploadFile", spFile, "\(fileSize) bytes")
|
||
totalBytes += fileSize
|
||
job.fileCompleted(skipped: false)
|
||
uploaded += 1
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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)
|
||
await sendCompletionNotification(result: result)
|
||
|
||
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() }
|
||
|
||
private func sendCompletionNotification(result: BackupResult) async {
|
||
let content = UNMutableNotificationContent()
|
||
content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete"
|
||
content.body = "\(result.uploadedCount) uploaded · \(result.skippedCount) skipped · \(result.failedCount) errors"
|
||
content.sound = .default
|
||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||
try? await UNUserNotificationCenter.current().add(request)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|